From ab4e321904ad14ba09729d87695fba889da0bc60 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Wed, 28 Jan 2026 19:24:50 +0100 Subject: [PATCH] refactor(cli): storage and flags management (#2812) --- cmd/certs_storage_test.go | 115 ------- cmd/cmd_before.go | 31 -- cmd/cmd_dnshelp.go | 16 +- cmd/cmd_list.go | 42 +-- cmd/cmd_renew.go | 188 ++++++----- cmd/cmd_revoke.go | 49 +-- cmd/cmd_run.go | 122 +++---- cmd/flags.go | 304 ++++++++++++------ cmd/hook.go | 99 +----- cmd/internal/hook/hook.go | 84 +++++ cmd/{ => internal/hook}/hook_test.go | 21 +- .../hook}/testdata/sleeping_beauty.sh | 0 cmd/{ => internal/hook}/testdata/sleepy.sh | 0 cmd/{ => internal/storage}/account.go | 6 +- .../storage}/accounts_storage.go | 119 ++++--- cmd/internal/storage/accounts_storage_test.go | 223 +++++++++++++ cmd/internal/storage/certificates.go | 38 +++ cmd/internal/storage/certificates_reader.go | 93 ++++++ .../storage/certificates_reader_test.go | 141 ++++++++ .../storage/certificates_writer.go} | 227 +++++-------- .../storage/certificates_writer_test.go | 285 ++++++++++++++++ cmd/internal/storage/testdata/account.json | 16 + .../keys/test@example.com.key | 51 +++ .../testdata/certificates/example.com.crt | 1 + .../certificates/example.com.issuer.crt | 1 + .../testdata/certificates/example.com.json | 5 + .../testdata/certificates/example.com.key | 1 + .../testdata/certificates/example.org.crt | 11 + cmd/lego/main.go | 4 - cmd/setup.go | 21 +- cmd/storages.go | 52 +++ docs/data/zz_cli_help.toml | 189 +++++++---- e2e/challenges_test.go | 25 +- e2e/dnschallenge/dns_challenges_test.go | 3 +- internal/clihelp/generator.go | 2 - 35 files changed, 1759 insertions(+), 826 deletions(-) delete mode 100644 cmd/certs_storage_test.go delete mode 100644 cmd/cmd_before.go create mode 100644 cmd/internal/hook/hook.go rename cmd/{ => internal/hook}/hook_test.go (69%) rename cmd/{ => internal/hook}/testdata/sleeping_beauty.sh (100%) rename cmd/{ => internal/hook}/testdata/sleepy.sh (100%) rename cmd/{ => internal/storage}/account.go (85%) rename cmd/{ => internal/storage}/accounts_storage.go (83%) create mode 100644 cmd/internal/storage/accounts_storage_test.go create mode 100644 cmd/internal/storage/certificates.go create mode 100644 cmd/internal/storage/certificates_reader.go create mode 100644 cmd/internal/storage/certificates_reader_test.go rename cmd/{certs_storage.go => internal/storage/certificates_writer.go} (56%) create mode 100644 cmd/internal/storage/certificates_writer_test.go create mode 100644 cmd/internal/storage/testdata/account.json create mode 100644 cmd/internal/storage/testdata/accounts/test@example.com/keys/test@example.com.key create mode 100644 cmd/internal/storage/testdata/certificates/example.com.crt create mode 100644 cmd/internal/storage/testdata/certificates/example.com.issuer.crt create mode 100644 cmd/internal/storage/testdata/certificates/example.com.json create mode 100644 cmd/internal/storage/testdata/certificates/example.com.key create mode 100644 cmd/internal/storage/testdata/certificates/example.org.crt create mode 100644 cmd/storages.go diff --git a/cmd/certs_storage_test.go b/cmd/certs_storage_test.go deleted file mode 100644 index 9a474f18b..000000000 --- a/cmd/certs_storage_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - "regexp" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCertificatesStorage_MoveToArchive(t *testing.T) { - domain := "example.com" - - storage := CertificatesStorage{ - rootPath: t.TempDir(), - archivePath: t.TempDir(), - } - - domainFiles := generateTestFiles(t, storage.rootPath, domain) - - err := storage.MoveToArchive(domain) - require.NoError(t, err) - - for _, file := range domainFiles { - assert.NoFileExists(t, file) - } - - root, err := os.ReadDir(storage.rootPath) - require.NoError(t, err) - require.Empty(t, root) - - archive, err := os.ReadDir(storage.archivePath) - require.NoError(t, err) - - require.Len(t, archive, len(domainFiles)) - assert.Regexp(t, `\d+\.`+regexp.QuoteMeta(domain), archive[0].Name()) -} - -func TestCertificatesStorage_MoveToArchive_noFileRelatedToDomain(t *testing.T) { - domain := "example.com" - - storage := CertificatesStorage{ - rootPath: t.TempDir(), - archivePath: t.TempDir(), - } - - domainFiles := generateTestFiles(t, storage.rootPath, "example.org") - - err := storage.MoveToArchive(domain) - require.NoError(t, err) - - for _, file := range domainFiles { - assert.FileExists(t, file) - } - - root, err := os.ReadDir(storage.rootPath) - require.NoError(t, err) - assert.Len(t, root, len(domainFiles)) - - archive, err := os.ReadDir(storage.archivePath) - require.NoError(t, err) - - assert.Empty(t, archive) -} - -func TestCertificatesStorage_MoveToArchive_ambiguousDomain(t *testing.T) { - domain := "example.com" - - storage := CertificatesStorage{ - rootPath: t.TempDir(), - archivePath: t.TempDir(), - } - - domainFiles := generateTestFiles(t, storage.rootPath, domain) - otherDomainFiles := generateTestFiles(t, storage.rootPath, domain+".example.org") - - err := storage.MoveToArchive(domain) - require.NoError(t, err) - - for _, file := range domainFiles { - assert.NoFileExists(t, file) - } - - for _, file := range otherDomainFiles { - assert.FileExists(t, file) - } - - root, err := os.ReadDir(storage.rootPath) - require.NoError(t, err) - require.Len(t, root, len(otherDomainFiles)) - - archive, err := os.ReadDir(storage.archivePath) - require.NoError(t, err) - - require.Len(t, archive, len(domainFiles)) - assert.Regexp(t, `\d+\.`+regexp.QuoteMeta(domain), archive[0].Name()) -} - -func generateTestFiles(t *testing.T, dir, domain string) []string { - t.Helper() - - var filenames []string - - for _, ext := range []string{issuerExt, certExt, keyExt, pemExt, pfxExt, resourceExt} { - filename := filepath.Join(dir, domain+ext) - err := os.WriteFile(filename, []byte("test"), 0o666) - require.NoError(t, err) - - filenames = append(filenames, filename) - } - - return filenames -} diff --git a/cmd/cmd_before.go b/cmd/cmd_before.go deleted file mode 100644 index dde95f184..000000000 --- a/cmd/cmd_before.go +++ /dev/null @@ -1,31 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "log/slog" - - "github.com/go-acme/lego/v5/log" - "github.com/urfave/cli/v3" -) - -func Before(ctx context.Context, cmd *cli.Command) (context.Context, error) { - if cmd.String(flgPath) == "" { - log.Fatal(fmt.Sprintf("Could not determine the current working directory. Please pass --%s.", flgPath)) - } - - err := createNonExistingFolder(cmd.String(flgPath)) - if err != nil { - log.Fatal("Could not check/create the path.", - slog.String("flag", flgPath), - slog.String("filepath", cmd.String(flgPath)), - log.ErrorAttr(err), - ) - } - - if cmd.String(flgServer) == "" { - log.Fatal(fmt.Sprintf("Could not determine the current working server. Please pass --%s.", flgServer)) - } - - return ctx, nil -} diff --git a/cmd/cmd_dnshelp.go b/cmd/cmd_dnshelp.go index e7b9ad85c..42a1d9c7a 100644 --- a/cmd/cmd_dnshelp.go +++ b/cmd/cmd_dnshelp.go @@ -17,12 +17,16 @@ func createDNSHelp() *cli.Command { Name: "dnshelp", Usage: "Shows additional help for the '--dns' global option", Action: dnsHelp, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: flgCode, - Aliases: []string{"c"}, - Usage: fmt.Sprintf("DNS code: %s", allDNSCodes()), - }, + Flags: createDNSHelpFlags(), + } +} + +func createDNSHelpFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: flgCode, + Aliases: []string{"c"}, + Usage: fmt.Sprintf("DNS code: %s", allDNSCodes()), }, } } diff --git a/cmd/cmd_list.go b/cmd/cmd_list.go index 5b1c015e0..e12c7762a 100644 --- a/cmd/cmd_list.go +++ b/cmd/cmd_list.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/go-acme/lego/v5/certcrypto" + "github.com/go-acme/lego/v5/cmd/internal/storage" "github.com/urfave/cli/v3" ) @@ -23,24 +24,23 @@ func createList() *cli.Command { Name: "list", Usage: "Display certificates and accounts information.", Action: list, - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: flgAccounts, - Aliases: []string{"a"}, - Usage: "Display accounts.", - }, - &cli.BoolFlag{ - Name: flgNames, - Aliases: []string{"n"}, - Usage: "Display certificate common names only.", - }, - // fake email, needed by NewAccountsStorage - &cli.StringFlag{ - Name: flgEmail, - Value: "", - Hidden: true, - }, + Flags: createListFlags(), + } +} + +func createListFlags() []cli.Flag { + return []cli.Flag{ + &cli.BoolFlag{ + Name: flgAccounts, + Aliases: []string{"a"}, + Usage: "Display accounts.", }, + &cli.BoolFlag{ + Name: flgNames, + Aliases: []string{"n"}, + Usage: "Display certificate names only.", + }, + CreatePathFlag(false), } } @@ -55,7 +55,7 @@ func list(ctx context.Context, cmd *cli.Command) error { } func listCertificates(_ context.Context, cmd *cli.Command) error { - certsStorage := NewCertificatesStorage(cmd) + certsStorage := storage.NewCertificatesReader(cmd.String(flgPath)) matches, err := filepath.Glob(filepath.Join(certsStorage.GetRootPath(), "*.crt")) if err != nil { @@ -77,7 +77,7 @@ func listCertificates(_ context.Context, cmd *cli.Command) error { } for _, filename := range matches { - if strings.HasSuffix(filename, issuerExt) { + if strings.HasSuffix(filename, storage.ExtIssuer) { continue } @@ -111,7 +111,7 @@ func listCertificates(_ context.Context, cmd *cli.Command) error { } func listAccount(_ context.Context, cmd *cli.Command) error { - accountsStorage := NewAccountsStorage(cmd) + accountsStorage := newAccountsStorage(cmd) matches, err := filepath.Glob(filepath.Join(accountsStorage.GetRootPath(), "*", "*", "*.json")) if err != nil { @@ -131,7 +131,7 @@ func listAccount(_ context.Context, cmd *cli.Command) error { return err } - var account Account + var account storage.Account err = json.Unmarshal(data, &account) if err != nil { diff --git a/cmd/cmd_renew.go b/cmd/cmd_renew.go index c477a9bd1..3efbef272 100644 --- a/cmd/cmd_renew.go +++ b/cmd/cmd_renew.go @@ -17,6 +17,8 @@ import ( "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" @@ -60,101 +62,109 @@ func createRenew() *cli.Command { return ctx, nil }, - Flags: []cli.Flag{ - &cli.IntFlag{ - Name: flgRenewDays, - Value: 30, - Usage: "The number of days left on a certificate to renew it.", - }, - // TODO(ldez): in v5, remove this flag, use this behavior as default. - &cli.BoolFlag{ - Name: flgRenewDynamic, - Value: false, - Usage: "Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5.", - }, - &cli.BoolFlag{ - Name: flgARIDisable, - Usage: "Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed.", - }, - &cli.DurationFlag{ - Name: flgARIWaitToRenewDuration, - Usage: "The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint.", - }, - &cli.BoolFlag{ - Name: flgReuseKey, - Usage: "Used to indicate you want to reuse your current private key for the new certificate.", - }, - &cli.BoolFlag{ - Name: flgNoBundle, - Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.", - }, - &cli.BoolFlag{ - Name: flgMustStaple, - Usage: "Include the OCSP must staple TLS extension in the CSR and generated certificate." + - " Only works if the CSR is generated by lego.", - }, - &cli.TimestampFlag{ - Name: flgNotBefore, - Usage: "Set the notBefore field in the certificate (RFC3339 format)", - Config: cli.TimestampConfig{ - Layouts: []string{time.RFC3339}, - }, - }, - &cli.TimestampFlag{ - Name: flgNotAfter, - Usage: "Set the notAfter field in the certificate (RFC3339 format)", - Config: cli.TimestampConfig{ - Layouts: []string{time.RFC3339}, - }, - }, - &cli.StringFlag{ - Name: flgPreferredChain, - Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name." + - " If no match, the default offered chain will be used.", - }, - &cli.StringFlag{ - Name: flgProfile, - Usage: "If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.", - }, - &cli.StringFlag{ - Name: flgAlwaysDeactivateAuthorizations, - Usage: "Force the authorizations to be relinquished even if the certificate request was successful.", - }, - &cli.StringFlag{ - Name: flgRenewHook, - Usage: "Define a hook. The hook is executed only when the certificates are effectively renewed.", - }, - &cli.DurationFlag{ - Name: flgRenewHookTimeout, - Usage: "Define the timeout for the hook execution.", - Value: 2 * time.Minute, - }, - &cli.BoolFlag{ - Name: flgNoRandomSleep, - Usage: "Do not add a random sleep before the renewal." + - " We do not recommend using this flag if you are doing your renewals in an automated way.", - }, - &cli.BoolFlag{ - Name: flgForceCertDomains, - Usage: "Check and ensure that the cert's domain list matches those passed in the domains argument.", - }, - }, + Flags: createRenewFlags(), } } +func createRenewFlags() []cli.Flag { + flags := CreateFlags() + + flags = append(flags, + &cli.IntFlag{ + Name: flgRenewDays, + Value: 30, + Usage: "The number of days left on a certificate to renew it.", + }, + // TODO(ldez): in v5, remove this flag, use this behavior as default. + &cli.BoolFlag{ + Name: flgRenewDynamic, + Value: false, + Usage: "Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5.", + }, + &cli.BoolFlag{ + Name: flgARIDisable, + Usage: "Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed.", + }, + &cli.DurationFlag{ + Name: flgARIWaitToRenewDuration, + Usage: "The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint.", + }, + &cli.BoolFlag{ + Name: flgReuseKey, + Usage: "Used to indicate you want to reuse your current private key for the new certificate.", + }, + &cli.BoolFlag{ + Name: flgNoBundle, + Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.", + }, + &cli.BoolFlag{ + Name: flgMustStaple, + Usage: "Include the OCSP must staple TLS extension in the CSR and generated certificate." + + " Only works if the CSR is generated by lego.", + }, + &cli.TimestampFlag{ + Name: flgNotBefore, + Usage: "Set the notBefore field in the certificate (RFC3339 format)", + Config: cli.TimestampConfig{ + Layouts: []string{time.RFC3339}, + }, + }, + &cli.TimestampFlag{ + Name: flgNotAfter, + Usage: "Set the notAfter field in the certificate (RFC3339 format)", + Config: cli.TimestampConfig{ + Layouts: []string{time.RFC3339}, + }, + }, + &cli.StringFlag{ + Name: flgPreferredChain, + Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name." + + " If no match, the default offered chain will be used.", + }, + &cli.StringFlag{ + Name: flgProfile, + Usage: "If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.", + }, + &cli.StringFlag{ + Name: flgAlwaysDeactivateAuthorizations, + Usage: "Force the authorizations to be relinquished even if the certificate request was successful.", + }, + &cli.StringFlag{ + Name: flgRenewHook, + Usage: "Define a hook. The hook is executed only when the certificates are effectively renewed.", + }, + &cli.DurationFlag{ + Name: flgRenewHookTimeout, + Usage: "Define the timeout for the hook execution.", + Value: 2 * time.Minute, + }, + &cli.BoolFlag{ + Name: flgNoRandomSleep, + Usage: "Do not add a random sleep before the renewal." + + " We do not recommend using this flag if you are doing your renewals in an automated way.", + }, + &cli.BoolFlag{ + Name: flgForceCertDomains, + Usage: "Check and ensure that the cert's domain list matches those passed in the domains argument.", + }, + ) + + return flags +} + func renew(ctx context.Context, cmd *cli.Command) error { - account, keyType := setupAccount(ctx, cmd, NewAccountsStorage(cmd)) + account, keyType := setupAccount(ctx, cmd, newAccountsStorage(cmd)) if account.Registration == nil { log.Fatal("The account is not registered. Use 'run' to register a new account.", slog.String("email", account.Email)) } - certsStorage := NewCertificatesStorage(cmd) + certsStorage := newCertificatesStorage(cmd) bundle := !cmd.Bool(flgNoBundle) meta := map[string]string{ - hookEnvAccountEmail: account.Email, + hook.EnvAccountEmail: account.Email, } // CSR @@ -166,14 +176,14 @@ func renew(ctx context.Context, cmd *cli.Command) error { return renewForDomains(ctx, cmd, account, keyType, certsStorage, bundle, meta) } -func renewForDomains(ctx context.Context, cmd *cli.Command, account *Account, keyType certcrypto.KeyType, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error { +func renewForDomains(ctx context.Context, cmd *cli.Command, account *storage.Account, keyType certcrypto.KeyType, certsStorage *CertificatesStorage, bundle bool, 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, certExt) + certificates, err := certsStorage.ReadCertificate(domain, storage.ExtCert) if err != nil { log.Fatal("Error while loading the certificate.", log.DomainAttr(domain), log.ErrorAttr(err)) } @@ -234,7 +244,7 @@ func renewForDomains(ctx context.Context, cmd *cli.Command, account *Account, ke var privateKey crypto.PrivateKey if cmd.Bool(flgReuseKey) { - keyBytes, errR := certsStorage.ReadFile(domain, keyExt) + keyBytes, errR := certsStorage.ReadFile(domain, storage.ExtKey) if errR != nil { log.Fatal("Error while loading the private key.", log.DomainAttr(domain), @@ -293,10 +303,10 @@ func renewForDomains(ctx context.Context, cmd *cli.Command, account *Account, ke addPathToMetadata(meta, domain, certRes, certsStorage) - return launchHook(ctx, cmd.String(flgRenewHook), cmd.Duration(flgRenewHookTimeout), meta) + return hook.Launch(ctx, cmd.String(flgRenewHook), cmd.Duration(flgRenewHookTimeout), meta) } -func renewForCSR(ctx context.Context, cmd *cli.Command, account *Account, keyType certcrypto.KeyType, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error { +func renewForCSR(ctx context.Context, cmd *cli.Command, account *storage.Account, keyType certcrypto.KeyType, certsStorage *CertificatesStorage, bundle bool, meta map[string]string) error { csr, err := readCSRFile(cmd.String(flgCSR)) if err != nil { log.Fatal("Could not read CSR file.", @@ -314,7 +324,7 @@ func renewForCSR(ctx context.Context, cmd *cli.Command, account *Account, keyTyp // 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, certExt) + certificates, err := certsStorage.ReadCertificate(domain, storage.ExtCert) if err != nil { log.Fatal("Error while loading the certificate.", log.DomainAttr(domain), @@ -392,7 +402,7 @@ func renewForCSR(ctx context.Context, cmd *cli.Command, account *Account, keyTyp addPathToMetadata(meta, domain, certRes, certsStorage) - return launchHook(ctx, cmd.String(flgRenewHook), cmd.Duration(flgRenewHookTimeout), meta) + return hook.Launch(ctx, cmd.String(flgRenewHook), cmd.Duration(flgRenewHookTimeout), meta) } func needRenewal(x509Cert *x509.Certificate, domain string, days int, dynamic bool) bool { diff --git a/cmd/cmd_revoke.go b/cmd/cmd_revoke.go index 13eff52d4..dc0bc5cf4 100644 --- a/cmd/cmd_revoke.go +++ b/cmd/cmd_revoke.go @@ -5,6 +5,7 @@ import ( "log/slog" "github.com/go-acme/lego/v5/acme" + "github.com/go-acme/lego/v5/cmd/internal/storage" "github.com/go-acme/lego/v5/log" "github.com/urfave/cli/v3" ) @@ -20,28 +21,36 @@ func createRevoke() *cli.Command { Name: "revoke", Usage: "Revoke a certificate", Action: revoke, - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: flgKeep, - Aliases: []string{"k"}, - Usage: "Keep the certificates after the revocation instead of archiving them.", - }, - &cli.UintFlag{ - Name: flgReason, - Usage: "Identifies the reason for the certificate revocation." + - " See https://www.rfc-editor.org/rfc/rfc5280.html#section-5.3.1." + - " Valid values are:" + - " 0 (unspecified), 1 (keyCompromise), 2 (cACompromise), 3 (affiliationChanged)," + - " 4 (superseded), 5 (cessationOfOperation), 6 (certificateHold), 8 (removeFromCRL)," + - " 9 (privilegeWithdrawn), or 10 (aACompromise).", - Value: acme.CRLReasonUnspecified, - }, - }, + Flags: createRevokeFlags(), } } +func createRevokeFlags() []cli.Flag { + flags := CreateFlags() + + flags = append(flags, + &cli.BoolFlag{ + Name: flgKeep, + Aliases: []string{"k"}, + Usage: "Keep the certificates after the revocation instead of archiving them.", + }, + &cli.UintFlag{ + Name: flgReason, + Usage: "Identifies the reason for the certificate revocation." + + " See https://www.rfc-editor.org/rfc/rfc5280.html#section-5.3.1." + + " Valid values are:" + + " 0 (unspecified), 1 (keyCompromise), 2 (cACompromise), 3 (affiliationChanged)," + + " 4 (superseded), 5 (cessationOfOperation), 6 (certificateHold), 8 (removeFromCRL)," + + " 9 (privilegeWithdrawn), or 10 (aACompromise).", + Value: acme.CRLReasonUnspecified, + }, + ) + + return flags +} + func revoke(ctx context.Context, cmd *cli.Command) error { - account, keyType := setupAccount(ctx, cmd, NewAccountsStorage(cmd)) + account, keyType := setupAccount(ctx, cmd, newAccountsStorage(cmd)) if account.Registration == nil { log.Fatal("Account is not registered. Use 'run' to register a new account.", slog.String("email", account.Email)) @@ -49,13 +58,13 @@ func revoke(ctx context.Context, cmd *cli.Command) error { client := newClient(cmd, account, keyType) - certsStorage := NewCertificatesStorage(cmd) + certsStorage := newCertificatesStorage(cmd) certsStorage.CreateRootFolder() for _, domain := range cmd.StringSlice(flgDomains) { log.Info("Trying to revoke the certificate.", log.DomainAttr(domain)) - certBytes, err := certsStorage.ReadFile(domain, certExt) + certBytes, err := certsStorage.ReadFile(domain, storage.ExtCert) if err != nil { log.Fatal("Error while revoking the certificate.", log.DomainAttr(domain), log.ErrorAttr(err)) } diff --git a/cmd/cmd_run.go b/cmd/cmd_run.go index 4b89fdc9f..b607e57b0 100644 --- a/cmd/cmd_run.go +++ b/cmd/cmd_run.go @@ -10,6 +10,8 @@ import ( "time" "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/go-acme/lego/v5/registration" @@ -50,58 +52,66 @@ func createRun() *cli.Command { return ctx, nil }, Action: run, - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: flgNoBundle, - Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.", - }, - &cli.BoolFlag{ - Name: flgMustStaple, - Usage: "Include the OCSP must staple TLS extension in the CSR and generated certificate." + - " Only works if the CSR is generated by lego.", - }, - &cli.TimestampFlag{ - Name: flgNotBefore, - Usage: "Set the notBefore field in the certificate (RFC3339 format)", - Config: cli.TimestampConfig{ - Layouts: []string{time.RFC3339}, - }, - }, - &cli.TimestampFlag{ - Name: flgNotAfter, - Usage: "Set the notAfter field in the certificate (RFC3339 format)", - Config: cli.TimestampConfig{ - Layouts: []string{time.RFC3339}, - }, - }, - &cli.StringFlag{ - Name: flgPrivateKey, - Usage: "Path to private key (in PEM encoding) for the certificate. By default, the private key is generated.", - }, - &cli.StringFlag{ - Name: flgPreferredChain, - Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name." + - " If no match, the default offered chain will be used.", - }, - &cli.StringFlag{ - Name: flgProfile, - Usage: "If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.", - }, - &cli.StringFlag{ - Name: flgAlwaysDeactivateAuthorizations, - Usage: "Force the authorizations to be relinquished even if the certificate request was successful.", - }, - &cli.StringFlag{ - Name: flgRunHook, - Usage: "Define a hook. The hook is executed when the certificates are effectively created.", - }, - &cli.DurationFlag{ - Name: flgRunHookTimeout, - Usage: "Define the timeout for the hook execution.", - Value: 2 * time.Minute, + Flags: createRunFlags(), + } +} + +func createRunFlags() []cli.Flag { + flags := CreateFlags() + + flags = append(flags, + &cli.BoolFlag{ + Name: flgNoBundle, + Usage: "Do not create a certificate bundle by adding the issuers certificate to the new certificate.", + }, + &cli.BoolFlag{ + Name: flgMustStaple, + Usage: "Include the OCSP must staple TLS extension in the CSR and generated certificate." + + " Only works if the CSR is generated by lego.", + }, + &cli.TimestampFlag{ + Name: flgNotBefore, + Usage: "Set the notBefore field in the certificate (RFC3339 format)", + Config: cli.TimestampConfig{ + Layouts: []string{time.RFC3339}, }, }, - } + &cli.TimestampFlag{ + Name: flgNotAfter, + Usage: "Set the notAfter field in the certificate (RFC3339 format)", + Config: cli.TimestampConfig{ + Layouts: []string{time.RFC3339}, + }, + }, + &cli.StringFlag{ + Name: flgPrivateKey, + Usage: "Path to private key (in PEM encoding) for the certificate. By default, the private key is generated.", + }, + &cli.StringFlag{ + Name: flgPreferredChain, + Usage: "If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name." + + " If no match, the default offered chain will be used.", + }, + &cli.StringFlag{ + Name: flgProfile, + Usage: "If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one.", + }, + &cli.StringFlag{ + Name: flgAlwaysDeactivateAuthorizations, + Usage: "Force the authorizations to be relinquished even if the certificate request was successful.", + }, + &cli.StringFlag{ + Name: flgRunHook, + Usage: "Define a hook. The hook is executed when the certificates are effectively created.", + }, + &cli.DurationFlag{ + Name: flgRunHookTimeout, + Usage: "Define the timeout for the hook execution.", + Value: 2 * time.Minute, + }, + ) + + return flags } const rootPathWarningMessage = `!!!! HEADS UP !!!! @@ -116,7 +126,7 @@ server. Making regular backups of this folder is ideal. ` func run(ctx context.Context, cmd *cli.Command) error { - accountsStorage := NewAccountsStorage(cmd) + accountsStorage := newAccountsStorage(cmd) account, keyType := setupAccount(ctx, cmd, accountsStorage) @@ -136,7 +146,7 @@ func run(ctx context.Context, cmd *cli.Command) error { fmt.Printf(rootPathWarningMessage, accountsStorage.GetRootPath()) } - certsStorage := NewCertificatesStorage(cmd) + certsStorage := newCertificatesStorage(cmd) certsStorage.CreateRootFolder() cert, err := obtainCertificate(ctx, cmd, client) @@ -149,12 +159,12 @@ func run(ctx context.Context, cmd *cli.Command) error { certsStorage.SaveResource(cert) meta := map[string]string{ - hookEnvAccountEmail: account.Email, + hook.EnvAccountEmail: account.Email, } addPathToMetadata(meta, cert.Domain, cert, certsStorage) - return launchHook(ctx, cmd.String(flgRunHook), cmd.Duration(flgRunHookTimeout), meta) + return hook.Launch(ctx, cmd.String(flgRunHook), cmd.Duration(flgRunHookTimeout), meta) } func handleTOS(cmd *cli.Command, client *lego.Client) bool { @@ -231,7 +241,7 @@ func obtainCertificate(ctx context.Context, cmd *cli.Command, client *lego.Clien if cmd.IsSet(flgPrivateKey) { var err error - request.PrivateKey, err = loadPrivateKey(cmd.String(flgPrivateKey)) + request.PrivateKey, err = storage.LoadPrivateKey(cmd.String(flgPrivateKey)) if err != nil { return nil, fmt.Errorf("load private key: %w", err) } @@ -260,7 +270,7 @@ func obtainCertificate(ctx context.Context, cmd *cli.Command, client *lego.Clien if cmd.IsSet(flgPrivateKey) { var err error - request.PrivateKey, err = loadPrivateKey(cmd.String(flgPrivateKey)) + request.PrivateKey, err = storage.LoadPrivateKey(cmd.String(flgPrivateKey)) if err != nil { return nil, fmt.Errorf("load private key: %w", err) } diff --git a/cmd/flags.go b/cmd/flags.go index de8e7f6a7..0f122e32e 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -6,53 +6,75 @@ import ( "path/filepath" "github.com/go-acme/lego/v5/certificate" + "github.com/go-acme/lego/v5/cmd/internal/storage" "github.com/go-acme/lego/v5/lego" "github.com/urfave/cli/v3" "software.sslmate.com/src/go-pkcs12" ) -// Flag names. +// Flag names related to the account and domains. +const ( + flgDomains = "domains" + flgAcceptTOS = "accept-tos" + flgEmail = "email" + flgCSR = "csr" + flgEAB = "eab" + flgKID = "kid" + flgHMAC = "hmac" +) + +// Flag names related to the output. +const ( + flgFilename = "filename" + flgPath = "path" + flgPEM = "pem" + flgPFX = "pfx" + flgPFXPass = "pfx.pass" + flgPFXFormat = "pfx.format" +) + +// Flag names related to the ACME client. +const ( + flgServer = "server" + flgDisableCommonName = "disable-cn" + flgKeyType = "key-type" + flgHTTPTimeout = "http-timeout" + flgTLSSkipVerify = "tls-skip-verify" + flgCertTimeout = "cert.timeout" + flgOverallRequestLimit = "overall-request-limit" + flgUserAgent = "user-agent" +) + +// Flag names related to HTTP-01 challenge. +const ( + flgHTTP = "http" + flgHTTPPort = "http.port" + flgHTTPDelay = "http.delay" + flgHTTPProxyHeader = "http.proxy-header" + flgHTTPWebroot = "http.webroot" + flgHTTPMemcachedHost = "http.memcached-host" + flgHTTPS3Bucket = "http.s3-bucket" +) + +// Flag names related to TLS-ALPN-01 challenge. +const ( + flgTLS = "tls" + flgTLSPort = "tls.port" + flgTLSDelay = "tls.delay" +) + +// Flag names related to DNS-01 challenge. const ( - flgDomains = "domains" - flgServer = "server" - flgAcceptTOS = "accept-tos" - flgEmail = "email" - flgDisableCommonName = "disable-cn" - flgCSR = "csr" - flgEAB = "eab" - flgKID = "kid" - flgHMAC = "hmac" - flgKeyType = "key-type" - flgFilename = "filename" - flgPath = "path" - flgHTTP = "http" - flgHTTPPort = "http.port" - flgHTTPDelay = "http.delay" - flgHTTPProxyHeader = "http.proxy-header" - flgHTTPWebroot = "http.webroot" - flgHTTPMemcachedHost = "http.memcached-host" - flgHTTPS3Bucket = "http.s3-bucket" - flgTLS = "tls" - flgTLSPort = "tls.port" - flgTLSDelay = "tls.delay" flgDNS = "dns" flgDNSDisableCP = "dns.disable-cp" flgDNSPropagationWait = "dns.propagation-wait" flgDNSPropagationDisableANS = "dns.propagation-disable-ans" flgDNSPropagationRNS = "dns.propagation-rns" flgDNSResolvers = "dns.resolvers" - flgHTTPTimeout = "http-timeout" - flgTLSSkipVerify = "tls-skip-verify" flgDNSTimeout = "dns-timeout" - flgPEM = "pem" - flgPFX = "pfx" - flgPFXPass = "pfx.pass" - flgPFXFormat = "pfx.format" - flgCertTimeout = "cert.timeout" - flgOverallRequestLimit = "overall-request-limit" - flgUserAgent = "user-agent" ) +// Environment variable names. const ( envEAB = "LEGO_EAB" envEABHMAC = "LEGO_EAB_HMAC" @@ -65,78 +87,53 @@ const ( envServer = "LEGO_SERVER" ) -func CreateFlags(defaultPath string) []cli.Flag { - if defaultPath == "" { - cwd, err := os.Getwd() - if err == nil { - defaultPath = filepath.Join(cwd, ".lego") - } - } - +func CreateACMEClientFlags() []cli.Flag { return []cli.Flag{ - &cli.StringSliceFlag{ - Name: flgDomains, - Aliases: []string{"d"}, - Usage: "Add a domain to the process. Can be specified multiple times.", - }, &cli.StringFlag{ - Name: flgServer, - Aliases: []string{"s"}, - Sources: cli.EnvVars(envServer), - Usage: "CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client.", - Value: lego.LEDirectoryProduction, - }, - &cli.BoolFlag{ - Name: flgAcceptTOS, - Aliases: []string{"a"}, - Usage: "By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.", - }, - &cli.StringFlag{ - Name: flgEmail, - Aliases: []string{"m"}, - Sources: cli.EnvVars(envEmail), - Usage: "Email used for registration and recovery contact.", + Name: flgServer, + Aliases: []string{"s"}, + Sources: cli.EnvVars(envServer), + Usage: "CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client.", + Value: lego.LEDirectoryProduction, + Required: true, }, &cli.BoolFlag{ Name: flgDisableCommonName, Usage: "Disable the use of the common name in the CSR.", }, - &cli.StringFlag{ - Name: flgCSR, - Aliases: []string{"c"}, - Usage: "Certificate signing request filename, if an external CSR is to be used.", - }, - &cli.BoolFlag{ - Name: flgEAB, - Sources: cli.EnvVars(envEAB), - Usage: "Use External Account Binding for account registration. Requires --kid and --hmac.", - }, - &cli.StringFlag{ - Name: flgKID, - Sources: cli.EnvVars(envEABKID), - Usage: "Key identifier from External CA. Used for External Account Binding.", - }, - &cli.StringFlag{ - Name: flgHMAC, - Sources: cli.EnvVars(envEABHMAC), - Usage: "MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding.", - }, &cli.StringFlag{ Name: flgKeyType, Aliases: []string{"k"}, Value: "ec256", Usage: "Key type to use for private keys. Supported: rsa2048, rsa3072, rsa4096, rsa8192, ec256, ec384.", }, - &cli.StringFlag{ - Name: flgFilename, - Usage: "(deprecated) Filename of the generated certificate.", + &cli.IntFlag{ + Name: flgHTTPTimeout, + Usage: "Set the HTTP timeout value to a specific value in seconds.", + }, + &cli.BoolFlag{ + Name: flgTLSSkipVerify, + Usage: "Skip the TLS verification of the ACME server.", + }, + &cli.IntFlag{ + Name: flgCertTimeout, + Usage: "Set the certificate timeout value to a specific value in seconds. Only used when obtaining certificates.", + Value: 30, + }, + &cli.IntFlag{ + Name: flgOverallRequestLimit, + Usage: "ACME overall requests limit.", + Value: certificate.DefaultOverallRequestLimit, }, &cli.StringFlag{ - Name: flgPath, - Sources: cli.EnvVars(envPath), - Usage: "Directory to use for storing the data.", - Value: defaultPath, + Name: flgUserAgent, + Usage: "Add to the user-agent sent to the CA to identify an application embedding lego-cli", }, + } +} + +func CreateHTTPChallengeFlags() []cli.Flag { + return []cli.Flag{ &cli.BoolFlag{ Name: flgHTTP, Usage: "Use the HTTP-01 challenge to solve challenges. Can be mixed with other types of challenges.", @@ -169,6 +166,11 @@ func CreateFlags(defaultPath string) []cli.Flag { Name: flgHTTPS3Bucket, Usage: "Set the S3 bucket name to use for HTTP-01 based challenges. Challenges will be written to the S3 bucket.", }, + } +} + +func CreateTLSChallengeFlags() []cli.Flag { + return []cli.Flag{ &cli.BoolFlag{ Name: flgTLS, Usage: "Use the TLS-ALPN-01 challenge to solve challenges. Can be mixed with other types of challenges.", @@ -183,6 +185,11 @@ func CreateFlags(defaultPath string) []cli.Flag { Usage: "Delay between the start of the TLS listener (use for TLSALPN-01 based challenges) and the validation of the challenge.", Value: 0, }, + } +} + +func CreateDNSChallengeFlags() []cli.Flag { + return []cli.Flag{ &cli.StringFlag{ Name: flgDNS, Usage: "Solve a DNS-01 challenge using the specified provider. Can be mixed with other types of challenges. Run 'lego dnshelp' for help on usage.", @@ -210,19 +217,21 @@ func CreateFlags(defaultPath string) []cli.Flag { " Supported: host:port." + " The default is to use the system resolvers, or Google's DNS resolvers if the system's cannot be determined.", }, - &cli.IntFlag{ - Name: flgHTTPTimeout, - Usage: "Set the HTTP timeout value to a specific value in seconds.", - }, - &cli.BoolFlag{ - Name: flgTLSSkipVerify, - Usage: "Skip the TLS verification of the ACME server.", - }, &cli.IntFlag{ Name: flgDNSTimeout, Usage: "Set the DNS timeout value to a specific value in seconds. Used only when performing authoritative name server queries.", Value: 10, }, + } +} + +func CreateOutputFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: flgFilename, + Usage: "(deprecated) Filename of the generated certificate.", + }, + CreatePathFlag(true), &cli.BoolFlag{ Name: flgPEM, Usage: "Generate an additional .pem (base64) file by concatenating the .key and .crt files together.", @@ -244,19 +253,102 @@ func CreateFlags(defaultPath string) []cli.Flag { Value: "RC2", Sources: cli.EnvVars(envPFXFormat), }, - &cli.IntFlag{ - Name: flgCertTimeout, - Usage: "Set the certificate timeout value to a specific value in seconds. Only used when obtaining certificates.", - Value: 30, + } +} + +func CreatePathFlag(forceCreation bool) cli.Flag { + return &cli.StringFlag{ + Name: flgPath, + Sources: cli.NewValueSourceChain(cli.EnvVar(envPath), &defaultPathValueSource{}), + Usage: "Directory to use for storing the data.", + Validator: func(s string) error { + if !forceCreation { + return nil + } + + err := storage.CreateNonExistingFolder(s) + if err != nil { + return fmt.Errorf("could not check/create the path %q: %w", s, err) + } + + return nil }, - &cli.IntFlag{ - Name: flgOverallRequestLimit, - Usage: "ACME overall requests limit.", - Value: certificate.DefaultOverallRequestLimit, + Required: true, + } +} + +func CreateAccountFlags() []cli.Flag { + return []cli.Flag{ + &cli.BoolFlag{ + Name: flgAcceptTOS, + Aliases: []string{"a"}, + Usage: "By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service.", }, &cli.StringFlag{ - Name: flgUserAgent, - Usage: "Add to the user-agent sent to the CA to identify an application embedding lego-cli", + Name: flgEmail, + Aliases: []string{"m"}, + Sources: cli.EnvVars(envEmail), + Usage: "Email used for registration and recovery contact.", + }, + &cli.StringFlag{ + Name: flgCSR, + Aliases: []string{"c"}, + Usage: "Certificate signing request filename, if an external CSR is to be used.", + }, + &cli.BoolFlag{ + Name: flgEAB, + Sources: cli.EnvVars(envEAB), + Usage: "Use External Account Binding for account registration. Requires --kid and --hmac.", + }, + &cli.StringFlag{ + Name: flgKID, + Sources: cli.EnvVars(envEABKID), + Usage: "Key identifier from External CA. Used for External Account Binding.", + }, + &cli.StringFlag{ + Name: flgHMAC, + Sources: cli.EnvVars(envEABHMAC), + Usage: "MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding.", }, } } + +func CreateFlags() []cli.Flag { + flags := []cli.Flag{ + &cli.StringSliceFlag{ + Name: flgDomains, + Aliases: []string{"d"}, + Usage: "Add a domain to the process. Can be specified multiple times.", + }, + } + + flags = append(flags, CreateAccountFlags()...) + flags = append(flags, CreateACMEClientFlags()...) + flags = append(flags, CreateOutputFlags()...) + flags = append(flags, CreateHTTPChallengeFlags()...) + flags = append(flags, CreateTLSChallengeFlags()...) + flags = append(flags, CreateDNSChallengeFlags()...) + + return flags +} + +// defaultPathValueSource gets the default path based on the current working directory. +// The field value is only here because clihelp/generator. +type defaultPathValueSource struct{} + +func (p *defaultPathValueSource) String() string { + return "default path" +} + +func (p *defaultPathValueSource) GoString() string { + return "&defaultPathValueSource{}" +} + +func (p *defaultPathValueSource) Lookup() (string, bool) { + cwd, err := os.Getwd() + if err != nil { + return "", false + } + + return filepath.Join(cwd, ".lego"), true +} diff --git a/cmd/hook.go b/cmd/hook.go index 294a0619b..4e7931e73 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -1,104 +1,25 @@ package cmd import ( - "bufio" - "context" - "errors" - "fmt" - "os" - "os/exec" - "strings" - "time" - "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" ) -const ( - hookEnvAccountEmail = "LEGO_ACCOUNT_EMAIL" - hookEnvCertDomain = "LEGO_CERT_DOMAIN" - hookEnvCertPath = "LEGO_CERT_PATH" - hookEnvCertKeyPath = "LEGO_CERT_KEY_PATH" - hookEnvIssuerCertKeyPath = "LEGO_ISSUER_CERT_PATH" - hookEnvCertPEMPath = "LEGO_CERT_PEM_PATH" - hookEnvCertPFXPath = "LEGO_CERT_PFX_PATH" -) - -func launchHook(ctx context.Context, hook string, timeout time.Duration, meta map[string]string) error { - if hook == "" { - return nil - } - - ctxCmd, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - parts := strings.Fields(hook) - - cmd := exec.CommandContext(ctxCmd, parts[0], parts[1:]...) - - cmd.Env = append(os.Environ(), metaToEnv(meta)...) - - stdout, err := cmd.StdoutPipe() - if err != nil { - return fmt.Errorf("create pipe: %w", err) - } - - cmd.Stderr = cmd.Stdout - - err = cmd.Start() - if err != nil { - return fmt.Errorf("start command: %w", err) - } - - go func() { - <-ctxCmd.Done() - - if ctxCmd.Err() != nil { - _ = cmd.Process.Kill() - _ = stdout.Close() - } - }() - - scanner := bufio.NewScanner(stdout) - for scanner.Scan() { - fmt.Println(scanner.Text()) - } - - err = cmd.Wait() - if err != nil { - if errors.Is(ctxCmd.Err(), context.DeadlineExceeded) { - return errors.New("hook timed out") - } - - return fmt.Errorf("wait command: %w", err) - } - - return nil -} - -func metaToEnv(meta map[string]string) []string { - var envs []string - - for k, v := range meta { - envs = append(envs, k+"="+v) - } - - return envs -} - func addPathToMetadata(meta map[string]string, domain string, certRes *certificate.Resource, certsStorage *CertificatesStorage) { - meta[hookEnvCertDomain] = domain - meta[hookEnvCertPath] = certsStorage.GetFileName(domain, certExt) - meta[hookEnvCertKeyPath] = certsStorage.GetFileName(domain, keyExt) + meta[hook.EnvCertDomain] = domain + meta[hook.EnvCertPath] = certsStorage.GetFileName(domain, storage.ExtCert) + meta[hook.EnvCertKeyPath] = certsStorage.GetFileName(domain, storage.ExtKey) if certRes.IssuerCertificate != nil { - meta[hookEnvIssuerCertKeyPath] = certsStorage.GetFileName(domain, issuerExt) + meta[hook.EnvIssuerCertKeyPath] = certsStorage.GetFileName(domain, storage.ExtIssuer) } - if certsStorage.pem { - meta[hookEnvCertPEMPath] = certsStorage.GetFileName(domain, pemExt) + if certsStorage.IsPEM() { + meta[hook.EnvCertPEMPath] = certsStorage.GetFileName(domain, storage.ExtPEM) } - if certsStorage.pfx { - meta[hookEnvCertPFXPath] = certsStorage.GetFileName(domain, pfxExt) + if certsStorage.IsPFX() { + meta[hook.EnvCertPFXPath] = certsStorage.GetFileName(domain, storage.ExtPFX) } } diff --git a/cmd/internal/hook/hook.go b/cmd/internal/hook/hook.go new file mode 100644 index 000000000..8a1aaf835 --- /dev/null +++ b/cmd/internal/hook/hook.go @@ -0,0 +1,84 @@ +package hook + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +const ( + EnvAccountEmail = "LEGO_ACCOUNT_EMAIL" + EnvCertDomain = "LEGO_CERT_DOMAIN" + EnvCertPath = "LEGO_CERT_PATH" + EnvCertKeyPath = "LEGO_CERT_KEY_PATH" + EnvIssuerCertKeyPath = "LEGO_ISSUER_CERT_PATH" + EnvCertPEMPath = "LEGO_CERT_PEM_PATH" + EnvCertPFXPath = "LEGO_CERT_PFX_PATH" +) + +func Launch(ctx context.Context, hook string, timeout time.Duration, meta map[string]string) error { + if hook == "" { + return nil + } + + ctxCmd, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + parts := strings.Fields(hook) + + cmd := exec.CommandContext(ctxCmd, parts[0], parts[1:]...) + + cmd.Env = append(os.Environ(), metaToEnv(meta)...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("create pipe: %w", err) + } + + cmd.Stderr = cmd.Stdout + + err = cmd.Start() + if err != nil { + return fmt.Errorf("start command: %w", err) + } + + go func() { + <-ctxCmd.Done() + + if ctxCmd.Err() != nil { + _ = cmd.Process.Kill() + _ = stdout.Close() + } + }() + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + fmt.Println(scanner.Text()) + } + + err = cmd.Wait() + if err != nil { + if errors.Is(ctxCmd.Err(), context.DeadlineExceeded) { + return errors.New("hook timed out") + } + + return fmt.Errorf("wait command: %w", err) + } + + return nil +} + +func metaToEnv(meta map[string]string) []string { + var envs []string + + for k, v := range meta { + envs = append(envs, k+"="+v) + } + + return envs +} diff --git a/cmd/hook_test.go b/cmd/internal/hook/hook_test.go similarity index 69% rename from cmd/hook_test.go rename to cmd/internal/hook/hook_test.go index 8fe951329..26dae4762 100644 --- a/cmd/hook_test.go +++ b/cmd/internal/hook/hook_test.go @@ -1,19 +1,20 @@ -package cmd +package hook import ( "runtime" "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func Test_launchHook(t *testing.T) { - err := launchHook(t.Context(), "echo foo", 1*time.Second, map[string]string{}) +func Test_Launch(t *testing.T) { + err := Launch(t.Context(), "echo foo", 1*time.Second, map[string]string{}) require.NoError(t, err) } -func Test_launchHook_errors(t *testing.T) { +func Test_Launch_errors(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("skipping test on Windows") } @@ -54,8 +55,18 @@ func Test_launchHook_errors(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - err := launchHook(t.Context(), test.hook, test.timeout, map[string]string{}) + err := Launch(t.Context(), test.hook, test.timeout, map[string]string{}) require.EqualError(t, err, test.expected) }) } } + +func Test_metaToEnv(t *testing.T) { + env := metaToEnv(map[string]string{ + "foo": "bar", + }) + + expected := []string{"foo=bar"} + + assert.Equal(t, expected, env) +} diff --git a/cmd/testdata/sleeping_beauty.sh b/cmd/internal/hook/testdata/sleeping_beauty.sh similarity index 100% rename from cmd/testdata/sleeping_beauty.sh rename to cmd/internal/hook/testdata/sleeping_beauty.sh diff --git a/cmd/testdata/sleepy.sh b/cmd/internal/hook/testdata/sleepy.sh similarity index 100% rename from cmd/testdata/sleepy.sh rename to cmd/internal/hook/testdata/sleepy.sh diff --git a/cmd/account.go b/cmd/internal/storage/account.go similarity index 85% rename from cmd/account.go rename to cmd/internal/storage/account.go index 662e17338..1e9bb62f0 100644 --- a/cmd/account.go +++ b/cmd/internal/storage/account.go @@ -1,4 +1,4 @@ -package cmd +package storage import ( "crypto" @@ -13,6 +13,10 @@ type Account struct { key crypto.PrivateKey } +func NewAccount(email string, key crypto.PrivateKey) *Account { + return &Account{Email: email, key: key} +} + /** Implementation of the registration.User interface **/ // GetEmail returns the email address for the account. diff --git a/cmd/accounts_storage.go b/cmd/internal/storage/accounts_storage.go similarity index 83% rename from cmd/accounts_storage.go rename to cmd/internal/storage/accounts_storage.go index 1e4a31a19..39dad643e 100644 --- a/cmd/accounts_storage.go +++ b/cmd/internal/storage/accounts_storage.go @@ -1,10 +1,11 @@ -package cmd +package storage import ( "context" "crypto" "encoding/json" "encoding/pem" + "fmt" "log/slog" "net/url" "os" @@ -15,7 +16,6 @@ import ( "github.com/go-acme/lego/v5/lego" "github.com/go-acme/lego/v5/log" "github.com/go-acme/lego/v5/registration" - "github.com/urfave/cli/v3" ) const userIDPlaceholder = "noemail@example.com" @@ -26,6 +26,14 @@ const ( accountFileName = "account.json" ) +type AccountsStorageConfig struct { + Email string + BasePath string + + Server string + UserAgent string +} + // AccountsStorage A storage for account data. // // rootPath: @@ -63,32 +71,28 @@ type AccountsStorage struct { userID string email string rootPath string - rootUserPath string keysPath string accountFilePath string - cmd *cli.Command + + server *url.URL + userAgent string } // NewAccountsStorage Creates a new AccountsStorage. -func NewAccountsStorage(cmd *cli.Command) *AccountsStorage { - // TODO: move to account struct? - email := cmd.String(flgEmail) +func NewAccountsStorage(config AccountsStorageConfig) (*AccountsStorage, error) { + email := config.Email userID := email if userID == "" { userID = userIDPlaceholder } - serverURL, err := url.Parse(cmd.String(flgServer)) + serverURL, err := url.Parse(config.Server) if err != nil { - log.Fatal("URL parsing", - slog.String("flag", flgServer), - slog.String("serverURL", cmd.String(flgServer)), - log.ErrorAttr(err), - ) + return nil, fmt.Errorf("invalid server URL %q: %w", config.Server, err) } - rootPath := filepath.Join(cmd.String(flgPath), baseAccountsRootFolderName) + rootPath := filepath.Join(config.BasePath, baseAccountsRootFolderName) serverPath := strings.NewReplacer(":", "_", "/", string(os.PathSeparator)).Replace(serverURL.Host) accountsPath := filepath.Join(rootPath, serverPath) rootUserPath := filepath.Join(accountsPath, userID) @@ -97,20 +101,19 @@ func NewAccountsStorage(cmd *cli.Command) *AccountsStorage { userID: userID, email: email, rootPath: rootPath, - rootUserPath: rootUserPath, keysPath: filepath.Join(rootUserPath, baseKeysFolderName), accountFilePath: filepath.Join(rootUserPath, accountFileName), - cmd: cmd, - } + server: serverURL, + userAgent: config.UserAgent, + }, nil } func (s *AccountsStorage) ExistsAccountFilePath() bool { - accountFile := filepath.Join(s.rootUserPath, accountFileName) - if _, err := os.Stat(accountFile); os.IsNotExist(err) { + if _, err := os.Stat(s.accountFilePath); os.IsNotExist(err) { return false } else if err != nil { log.Fatal("Could not read the account file.", - slog.String("filepath", accountFile), + slog.String("filepath", s.accountFilePath), log.ErrorAttr(err), ) } @@ -122,10 +125,6 @@ func (s *AccountsStorage) GetRootPath() string { return s.rootPath } -func (s *AccountsStorage) GetRootUserPath() string { - return s.rootUserPath -} - func (s *AccountsStorage) GetUserID() string { return s.userID } @@ -165,7 +164,7 @@ func (s *AccountsStorage) LoadAccount(ctx context.Context, privateKey crypto.Pri account.key = privateKey if account.Registration == nil || account.Registration.Body.Status == "" { - reg, err := tryRecoverRegistration(ctx, s.cmd, privateKey) + reg, err := s.tryRecoverRegistration(ctx, privateKey) if err != nil { log.Fatal("Could not load the account file. Registration is nil.", slog.String("userID", s.GetUserID()), @@ -210,7 +209,7 @@ func (s *AccountsStorage) GetPrivateKey(keyType certcrypto.KeyType) crypto.Priva return privateKey } - privateKey, err := loadPrivateKey(accKeyPath) + privateKey, err := LoadPrivateKey(accKeyPath) if err != nil { log.Fatal("Could not load an RSA private key from the file.", slog.String("filepath", accKeyPath), @@ -222,7 +221,7 @@ func (s *AccountsStorage) GetPrivateKey(keyType certcrypto.KeyType) crypto.Priva } func (s *AccountsStorage) createKeysFolder() { - if err := createNonExistingFolder(s.keysPath); err != nil { + if err := CreateNonExistingFolder(s.keysPath); err != nil { log.Fatal("Could not check/create the directory for the account.", slog.String("userID", s.GetUserID()), log.ErrorAttr(err), @@ -230,6 +229,39 @@ func (s *AccountsStorage) createKeysFolder() { } } +func (s *AccountsStorage) tryRecoverRegistration(ctx context.Context, privateKey crypto.PrivateKey) (*registration.Resource, error) { + // couldn't load account but got a key. Try to look the account up. + config := lego.NewConfig(&Account{key: privateKey}) + config.CADirURL = s.server.String() + config.UserAgent = s.userAgent + + client, err := lego.NewClient(config) + if err != nil { + return nil, err + } + + reg, err := client.Registration.ResolveAccountByKey(ctx) + if err != nil { + return nil, err + } + + return reg, nil +} + +func LoadPrivateKey(file string) (crypto.PrivateKey, error) { + keyBytes, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + privateKey, err := certcrypto.ParsePEMPrivateKey(keyBytes) + if err != nil { + return nil, err + } + + return privateKey, nil +} + func generatePrivateKey(file string, keyType certcrypto.KeyType) (crypto.PrivateKey, error) { privateKey, err := certcrypto.GeneratePrivateKey(keyType) if err != nil { @@ -251,36 +283,3 @@ func generatePrivateKey(file string, keyType certcrypto.KeyType) (crypto.Private return privateKey, nil } - -func loadPrivateKey(file string) (crypto.PrivateKey, error) { - keyBytes, err := os.ReadFile(file) - if err != nil { - return nil, err - } - - privateKey, err := certcrypto.ParsePEMPrivateKey(keyBytes) - if err != nil { - return nil, err - } - - return privateKey, nil -} - -func tryRecoverRegistration(ctx context.Context, cmd *cli.Command, privateKey crypto.PrivateKey) (*registration.Resource, error) { - // couldn't load account but got a key. Try to look the account up. - config := lego.NewConfig(&Account{key: privateKey}) - config.CADirURL = cmd.String(flgServer) - config.UserAgent = getUserAgent(cmd) - - client, err := lego.NewClient(config) - if err != nil { - return nil, err - } - - reg, err := client.Registration.ResolveAccountByKey(ctx) - if err != nil { - return nil, err - } - - return reg, nil -} diff --git a/cmd/internal/storage/accounts_storage_test.go b/cmd/internal/storage/accounts_storage_test.go new file mode 100644 index 000000000..9d2dfac0b --- /dev/null +++ b/cmd/internal/storage/accounts_storage_test.go @@ -0,0 +1,223 @@ +package storage + +import ( + "crypto" + "crypto/rsa" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-acme/lego/v5/acme" + "github.com/go-acme/lego/v5/certcrypto" + "github.com/go-acme/lego/v5/registration" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAccountsStorage_GetUserID(t *testing.T) { + testCases := []struct { + desc string + email string + expected string + }{ + { + desc: "with email", + email: "test@example.com", + expected: "test@example.com", + }, + { + desc: "without email", + expected: "noemail@example.com", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + storage, err := NewAccountsStorage(AccountsStorageConfig{ + Email: test.email, + BasePath: t.TempDir(), + }) + require.NoError(t, err) + + assert.Equal(t, test.email, storage.GetEmail()) + assert.Equal(t, test.expected, storage.GetUserID()) + }) + } +} + +func TestAccountsStorage_ExistsAccountFilePath(t *testing.T) { + testCases := []struct { + desc string + setup func(t *testing.T, storage *AccountsStorage) + assert assert.BoolAssertionFunc + }{ + { + desc: "an account file exists", + setup: func(t *testing.T, storage *AccountsStorage) { + t.Helper() + + err := os.MkdirAll(filepath.Dir(storage.accountFilePath), 0o755) + require.NoError(t, err) + + err = os.WriteFile(storage.accountFilePath, []byte("test"), 0o644) + require.NoError(t, err) + }, + assert: assert.True, + }, + { + desc: "no account file", + assert: assert.False, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + storage, err := NewAccountsStorage(AccountsStorageConfig{ + BasePath: t.TempDir(), + }) + require.NoError(t, err) + + if test.setup != nil { + test.setup(t, storage) + } + + test.assert(t, storage.ExistsAccountFilePath()) + }) + } +} + +func TestAccountsStorage_GetRootPath(t *testing.T) { + basePath := t.TempDir() + + storage, err := NewAccountsStorage(AccountsStorageConfig{ + BasePath: basePath, + }) + require.NoError(t, err) + + assert.Truef(t, strings.HasPrefix(storage.GetRootPath(), basePath), + "%s is not prefixed with %s", storage.GetRootPath(), basePath) + + rootPath, err := filepath.Rel(basePath, storage.GetRootPath()) + require.NoError(t, err) + + assert.Equal(t, baseAccountsRootFolderName, rootPath) +} + +func TestAccountsStorage_Save(t *testing.T) { + basePath := t.TempDir() + + storage, err := NewAccountsStorage(AccountsStorageConfig{ + Email: "test@example.com", + BasePath: basePath, + }) + require.NoError(t, err) + + account := &Account{ + Email: "account@example.com", + Registration: ®istration.Resource{ + Body: acme.Account{ + Status: "valid", + Contact: []string{"contact@example.com"}, + TermsOfServiceAgreed: true, + Orders: "https://ame.example.com/orders/123456", + OnlyReturnExisting: true, + ExternalAccountBinding: []byte(`"EAB"`), + }, + URI: "https://ame.example.com", + }, + key: crypto.PrivateKey(""), + } + + err = os.MkdirAll(filepath.Dir(storage.accountFilePath), 0o755) + require.NoError(t, err) + + err = storage.Save(account) + require.NoError(t, err) + + require.FileExists(t, storage.accountFilePath) + + accountFilePath, err := filepath.Rel(basePath, storage.accountFilePath) + require.NoError(t, err) + + assert.Equal(t, filepath.Join(baseAccountsRootFolderName, "test@example.com", accountFileName), accountFilePath) + + file, err := os.ReadFile(storage.accountFilePath) + require.NoError(t, err) + + expected, err := os.ReadFile(filepath.Join("testdata", accountFileName)) + require.NoError(t, err) + + assert.JSONEq(t, string(expected), string(file)) +} + +func TestAccountsStorage_LoadAccount(t *testing.T) { + storage, err := NewAccountsStorage(AccountsStorageConfig{ + Email: "test@example.com", + BasePath: t.TempDir(), + }) + require.NoError(t, err) + + storage.accountFilePath = filepath.Join("testdata", accountFileName) + + account := storage.LoadAccount(t.Context(), "") + + expected := &Account{ + Email: "account@example.com", + Registration: ®istration.Resource{ + Body: acme.Account{ + Status: "valid", + Contact: []string{"contact@example.com"}, + TermsOfServiceAgreed: true, + Orders: "https://ame.example.com/orders/123456", + OnlyReturnExisting: true, + ExternalAccountBinding: []byte(`"EAB"`), + }, + URI: "https://ame.example.com", + }, + key: crypto.PrivateKey(""), + } + + assert.Equal(t, expected, account) +} + +func TestAccountsStorage_GetPrivateKey(t *testing.T) { + testCases := []struct { + desc string + basePath string + }{ + { + desc: "create a new private key", + }, + { + desc: "existing private key", + basePath: "testdata", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + if test.basePath == "" { + test.basePath = t.TempDir() + } + + storage, err := NewAccountsStorage(AccountsStorageConfig{ + Email: "test@example.com", + BasePath: test.basePath, + }) + require.NoError(t, err) + + expectedPath := filepath.Join(test.basePath, baseAccountsRootFolderName, "test@example.com", baseKeysFolderName, "test@example.com.key") + + privateKey := storage.GetPrivateKey(certcrypto.RSA4096) + + assert.FileExists(t, expectedPath) + + assert.IsType(t, &rsa.PrivateKey{}, privateKey) + }) + } +} diff --git a/cmd/internal/storage/certificates.go b/cmd/internal/storage/certificates.go new file mode 100644 index 000000000..42d75cb67 --- /dev/null +++ b/cmd/internal/storage/certificates.go @@ -0,0 +1,38 @@ +package storage + +import ( + "os" + "path/filepath" +) + +const ( + ExtIssuer = ".issuer.crt" + ExtCert = ".crt" + ExtKey = ".key" + ExtPEM = ".pem" + ExtPFX = ".pfx" + ExtResource = ".json" +) + +const ( + baseCertificatesFolderName = "certificates" + baseArchivesFolderName = "archives" +) + +func getCertificatesRootPath(basePath string) string { + return filepath.Join(basePath, baseCertificatesFolderName) +} + +func getCertificatesArchivePath(basePath string) string { + return filepath.Join(basePath, baseArchivesFolderName) +} + +func CreateNonExistingFolder(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return os.MkdirAll(path, 0o700) + } else if err != nil { + return err + } + + return nil +} diff --git a/cmd/internal/storage/certificates_reader.go b/cmd/internal/storage/certificates_reader.go new file mode 100644 index 000000000..24d0d5459 --- /dev/null +++ b/cmd/internal/storage/certificates_reader.go @@ -0,0 +1,93 @@ +package storage + +import ( + "crypto/x509" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/go-acme/lego/v5/certcrypto" + "github.com/go-acme/lego/v5/certificate" + "github.com/go-acme/lego/v5/log" + "golang.org/x/net/idna" +) + +type CertificatesReader struct { + rootPath string +} + +func NewCertificatesReader(basePath string) *CertificatesReader { + return &CertificatesReader{ + rootPath: getCertificatesRootPath(basePath), + } +} + +func (s *CertificatesReader) ReadResource(domain string) certificate.Resource { + raw, err := s.ReadFile(domain, ExtResource) + if err != nil { + log.Fatal("Error while loading the metadata.", + log.DomainAttr(domain), + log.ErrorAttr(err), + ) + } + + var resource certificate.Resource + if err = json.Unmarshal(raw, &resource); err != nil { + log.Fatal("Error while marshaling the metadata.", + log.DomainAttr(domain), + log.ErrorAttr(err), + ) + } + + return resource +} + +func (s *CertificatesReader) ExistsFile(domain, extension string) bool { + filePath := s.GetFileName(domain, extension) + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return false + } else if err != nil { + log.Fatal("File stat", slog.String("filepath", filePath), log.ErrorAttr(err)) + } + + return true +} + +func (s *CertificatesReader) ReadFile(domain, extension string) ([]byte, error) { + return os.ReadFile(s.GetFileName(domain, extension)) +} + +func (s *CertificatesReader) GetRootPath() string { + return s.rootPath +} + +func (s *CertificatesReader) GetFileName(domain, extension string) string { + filename := sanitizedDomain(domain) + extension + return filepath.Join(s.rootPath, filename) +} + +func (s *CertificatesReader) ReadCertificate(domain, extension string) ([]*x509.Certificate, error) { + content, err := s.ReadFile(domain, extension) + if err != nil { + return nil, err + } + + // The input may be a bundle or a single certificate. + return certcrypto.ParsePEMBundle(content) +} + +// sanitizedDomain Make sure no funny chars are in the cert names (like wildcards ;)). +func sanitizedDomain(domain string) string { + safe, err := idna.ToASCII(strings.NewReplacer(":", "-", "*", "_").Replace(domain)) + if err != nil { + log.Fatal("Could not sanitize the domain.", + log.DomainAttr(domain), + log.ErrorAttr(err), + ) + } + + return safe +} diff --git a/cmd/internal/storage/certificates_reader_test.go b/cmd/internal/storage/certificates_reader_test.go new file mode 100644 index 000000000..7dc6bc286 --- /dev/null +++ b/cmd/internal/storage/certificates_reader_test.go @@ -0,0 +1,141 @@ +package storage + +import ( + "path/filepath" + "testing" + + "github.com/go-acme/lego/v5/certificate" + "github.com/stretchr/testify/assert" +) + +func TestNewCertificatesWriter_ReadResource(t *testing.T) { + reader := NewCertificatesReader("testdata") + + resource := reader.ReadResource("example.com") + + expected := certificate.Resource{ + Domain: "example.com", + CertURL: "https://acme.example.org/cert/123", + CertStableURL: "https://acme.example.org/cert/456", + } + + assert.Equal(t, expected, resource) +} + +func TestNewCertificatesWriter_ExistsFile(t *testing.T) { + reader := NewCertificatesReader("testdata") + + testCases := []struct { + desc string + domain string + extension string + assert assert.BoolAssertionFunc + }{ + { + desc: "exists", + domain: "example.com", + extension: ExtResource, + assert: assert.True, + }, + { + desc: "not exists", + domain: "example.org", + extension: ExtResource, + assert: assert.False, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + test.assert(t, reader.ExistsFile(test.domain, test.extension)) + }) + } +} + +func TestNewCertificatesWriter_ReadFile(t *testing.T) { + reader := NewCertificatesReader("testdata") + + data, err := reader.ReadFile("example.com", ExtResource) + assert.NoError(t, err) + + assert.NotEmpty(t, data) +} + +func TestNewCertificatesWriter_GetRootPath(t *testing.T) { + basePath := t.TempDir() + + reader := NewCertificatesReader(basePath) + + rootPath := reader.GetRootPath() + + expected := filepath.Join(basePath, baseCertificatesFolderName) + + assert.Equal(t, expected, rootPath) +} + +func TestNewCertificatesWriter_GetFileName(t *testing.T) { + testCases := []struct { + desc string + domain string + extension string + expected string + }{ + { + desc: "simple", + domain: "example.com", + extension: ExtCert, + expected: "example.com.crt", + }, + { + desc: "hyphen", + domain: "test-acme.example.com", + extension: ExtResource, + expected: "test-acme.example.com.json", + }, + { + desc: "wildcard", + domain: "*.example.com", + extension: ExtKey, + expected: "_.example.com.key", + }, + { + desc: "colon", + domain: "acme:test.example.com", + extension: ExtResource, + expected: "acme-test.example.com.json", + }, + { + desc: "IDN", + domain: "测试.com", + extension: ExtResource, + expected: "xn--0zwm56d.com.json", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + basePath := t.TempDir() + + reader := NewCertificatesReader(basePath) + + filename := reader.GetFileName(test.domain, test.extension) + + expected := filepath.Join(basePath, baseCertificatesFolderName, test.expected) + + assert.Equal(t, expected, filename) + }) + } +} + +func TestNewCertificatesWriter_ReadCertificate(t *testing.T) { + reader := NewCertificatesReader("testdata") + + cert, err := reader.ReadCertificate("example.org", ExtCert) + assert.NoError(t, err) + + assert.NotEmpty(t, cert) +} diff --git a/cmd/certs_storage.go b/cmd/internal/storage/certificates_writer.go similarity index 56% rename from cmd/certs_storage.go rename to cmd/internal/storage/certificates_writer.go index 48a08d93f..433db84b9 100644 --- a/cmd/certs_storage.go +++ b/cmd/internal/storage/certificates_writer.go @@ -1,4 +1,4 @@ -package cmd +package storage import ( "bytes" @@ -17,26 +17,23 @@ import ( "github.com/go-acme/lego/v5/certcrypto" "github.com/go-acme/lego/v5/certificate" "github.com/go-acme/lego/v5/log" - "github.com/urfave/cli/v3" - "golang.org/x/net/idna" "software.sslmate.com/src/go-pkcs12" ) -const ( - baseCertificatesFolderName = "certificates" - baseArchivesFolderName = "archives" -) +const filePerm os.FileMode = 0o600 -const ( - issuerExt = ".issuer.crt" - certExt = ".crt" - keyExt = ".key" - pemExt = ".pem" - pfxExt = ".pfx" - resourceExt = ".json" -) +type CertificatesWriterConfig struct { + BasePath string -// CertificatesStorage a certificates' storage. + PEM bool + PFX bool + PFXFormat string + PFXPassword string + + Filename string // TODO(ldez): remove +} + +// CertificatesWriter a writer of certificate files. // // rootPath: // @@ -49,39 +46,42 @@ const ( // ./.lego/archives/ // │ └── archived certificates directory // └── "path" option -type CertificatesStorage struct { +type CertificatesWriter struct { rootPath string archivePath string - pem bool + + pem bool + pfx bool - pfxPassword string pfxFormat string - filename string // Deprecated + pfxPassword string + + filename string // TODO(ldez): remove } -// NewCertificatesStorage create a new certificates storage. -func NewCertificatesStorage(cmd *cli.Command) *CertificatesStorage { - pfxFormat := cmd.String(flgPFXFormat) - - switch pfxFormat { - case "DES", "RC2", "SHA256": - default: - log.Fatal("Invalid PFX format.", slog.String("format", pfxFormat)) +// NewCertificatesWriter create a new certificates storage writer. +func NewCertificatesWriter(config CertificatesWriterConfig) (*CertificatesWriter, error) { + if config.PFX { + switch config.PFXFormat { + case "DES", "RC2", "SHA256": + default: + return nil, fmt.Errorf("invalid PFX format: %s", config.PFXFormat) + } } - return &CertificatesStorage{ - rootPath: filepath.Join(cmd.String(flgPath), baseCertificatesFolderName), - archivePath: filepath.Join(cmd.String(flgPath), baseArchivesFolderName), - pem: cmd.Bool(flgPEM), - pfx: cmd.Bool(flgPFX), - pfxPassword: cmd.String(flgPFXPass), - pfxFormat: pfxFormat, - filename: cmd.String(flgFilename), - } + return &CertificatesWriter{ + rootPath: getCertificatesRootPath(config.BasePath), + archivePath: getCertificatesArchivePath(config.BasePath), + pem: config.PEM, + pfx: config.PFX, + pfxPassword: config.PFXPassword, + pfxFormat: config.PFXFormat, + filename: config.Filename, + }, nil } -func (s *CertificatesStorage) CreateRootFolder() { - err := createNonExistingFolder(s.rootPath) +func (s *CertificatesWriter) CreateRootFolder() { + err := CreateNonExistingFolder(s.rootPath) if err != nil { log.Fatal("Could not check/create the root folder", slog.String("filepath", s.rootPath), @@ -90,8 +90,8 @@ func (s *CertificatesStorage) CreateRootFolder() { } } -func (s *CertificatesStorage) CreateArchiveFolder() { - err := createNonExistingFolder(s.archivePath) +func (s *CertificatesWriter) CreateArchiveFolder() { + err := CreateNonExistingFolder(s.archivePath) if err != nil { log.Fatal("Could not check/create the archive folder.", slog.String("filepath", s.archivePath), @@ -100,16 +100,12 @@ func (s *CertificatesStorage) CreateArchiveFolder() { } } -func (s *CertificatesStorage) GetRootPath() string { - return s.rootPath -} - -func (s *CertificatesStorage) SaveResource(certRes *certificate.Resource) { +func (s *CertificatesWriter) SaveResource(certRes *certificate.Resource) { domain := certRes.Domain // We store the certificate, private key and metadata in different files // as web servers would not be able to work with a combined file. - err := s.WriteFile(domain, certExt, certRes.Certificate) + err := s.writeFile(domain, ExtCert, certRes.Certificate) if err != nil { log.Fatal("Unable to save Certificate.", log.DomainAttr(domain), @@ -118,7 +114,7 @@ func (s *CertificatesStorage) SaveResource(certRes *certificate.Resource) { } if certRes.IssuerCertificate != nil { - err = s.WriteFile(domain, issuerExt, certRes.IssuerCertificate) + err = s.writeFile(domain, ExtIssuer, certRes.IssuerCertificate) if err != nil { log.Fatal("Unable to save IssuerCertificate.", log.DomainAttr(domain), @@ -129,7 +125,7 @@ func (s *CertificatesStorage) SaveResource(certRes *certificate.Resource) { // if we were given a CSR, we don't know the private key if certRes.PrivateKey != nil { - err = s.WriteCertificateFiles(domain, certRes) + err = s.writeCertificateFiles(domain, certRes) if err != nil { log.Fatal("Unable to save PrivateKey.", log.DomainAttr(domain), log.ErrorAttr(err)) } @@ -146,7 +142,7 @@ func (s *CertificatesStorage) SaveResource(certRes *certificate.Resource) { ) } - err = s.WriteFile(domain, resourceExt, jsonBytes) + err = s.writeFile(domain, ExtResource, jsonBytes) if err != nil { log.Fatal("Unable to save CertResource.", log.DomainAttr(domain), @@ -155,85 +151,59 @@ func (s *CertificatesStorage) SaveResource(certRes *certificate.Resource) { } } -func (s *CertificatesStorage) ReadResource(domain string) certificate.Resource { - raw, err := s.ReadFile(domain, resourceExt) +func (s *CertificatesWriter) MoveToArchive(domain string) error { + baseFilename := filepath.Join(s.rootPath, sanitizedDomain(domain)) + + matches, err := filepath.Glob(baseFilename + ".*") if err != nil { - log.Fatal("Error while loading the metadata.", - log.DomainAttr(domain), - log.ErrorAttr(err), - ) + return err } - var resource certificate.Resource - if err = json.Unmarshal(raw, &resource); err != nil { - log.Fatal("Error while marshaling the metadata.", - log.DomainAttr(domain), - log.ErrorAttr(err), - ) + for _, oldFile := range matches { + if strings.TrimSuffix(oldFile, filepath.Ext(oldFile)) != baseFilename && oldFile != baseFilename+ExtIssuer { + continue + } + + date := strconv.FormatInt(time.Now().Unix(), 10) + filename := date + "." + filepath.Base(oldFile) + newFile := filepath.Join(s.archivePath, filename) + + err = os.Rename(oldFile, newFile) + if err != nil { + return err + } } - return resource + return nil } -func (s *CertificatesStorage) ExistsFile(domain, extension string) bool { - filePath := s.GetFileName(domain, extension) - - if _, err := os.Stat(filePath); os.IsNotExist(err) { - return false - } else if err != nil { - log.Fatal("File stat", slog.String("filepath", filePath), log.ErrorAttr(err)) - } - - return true +func (s *CertificatesWriter) GetArchivePath() string { + return s.archivePath } -func (s *CertificatesStorage) ReadFile(domain, extension string) ([]byte, error) { - return os.ReadFile(s.GetFileName(domain, extension)) +func (s *CertificatesWriter) IsPEM() bool { + return s.pem } -func (s *CertificatesStorage) GetFileName(domain, extension string) string { - filename := sanitizedDomain(domain) + extension - return filepath.Join(s.rootPath, filename) +func (s *CertificatesWriter) IsPFX() bool { + return s.pfx } -func (s *CertificatesStorage) ReadCertificate(domain, extension string) ([]*x509.Certificate, error) { - content, err := s.ReadFile(domain, extension) - if err != nil { - return nil, err - } - - // The input may be a bundle or a single certificate. - return certcrypto.ParsePEMBundle(content) -} - -func (s *CertificatesStorage) WriteFile(domain, extension string, data []byte) error { - var baseFileName string - if s.filename != "" { - baseFileName = s.filename - } else { - baseFileName = sanitizedDomain(domain) - } - - filePath := filepath.Join(s.rootPath, baseFileName+extension) - - return os.WriteFile(filePath, data, filePerm) -} - -func (s *CertificatesStorage) WriteCertificateFiles(domain string, certRes *certificate.Resource) error { - err := s.WriteFile(domain, keyExt, certRes.PrivateKey) +func (s *CertificatesWriter) writeCertificateFiles(domain string, certRes *certificate.Resource) error { + err := s.writeFile(domain, ExtKey, certRes.PrivateKey) if err != nil { return fmt.Errorf("unable to save key file: %w", err) } if s.pem { - err = s.WriteFile(domain, pemExt, bytes.Join([][]byte{certRes.Certificate, certRes.PrivateKey}, nil)) + err = s.writeFile(domain, ExtPEM, bytes.Join([][]byte{certRes.Certificate, certRes.PrivateKey}, nil)) if err != nil { return fmt.Errorf("unable to save PEM file: %w", err) } } if s.pfx { - err = s.WritePFXFile(domain, certRes) + err = s.writePFXFile(domain, certRes) if err != nil { return fmt.Errorf("unable to save PFX file: %w", err) } @@ -242,7 +212,7 @@ func (s *CertificatesStorage) WriteCertificateFiles(domain string, certRes *cert return nil } -func (s *CertificatesStorage) WritePFXFile(domain string, certRes *certificate.Resource) error { +func (s *CertificatesWriter) writePFXFile(domain string, certRes *certificate.Resource) error { certPemBlock, _ := pem.Decode(certRes.Certificate) if certPemBlock == nil { return fmt.Errorf("unable to parse Certificate for domain %s", domain) @@ -273,33 +243,23 @@ func (s *CertificatesStorage) WritePFXFile(domain string, certRes *certificate.R return fmt.Errorf("unable to encode PFX data for domain %s: %w", domain, err) } - return s.WriteFile(domain, pfxExt, pfxBytes) + return s.writeFile(domain, ExtPFX, pfxBytes) } -func (s *CertificatesStorage) MoveToArchive(domain string) error { - baseFilename := filepath.Join(s.rootPath, sanitizedDomain(domain)) - - matches, err := filepath.Glob(baseFilename + ".*") - if err != nil { - return err +func (s *CertificatesWriter) writeFile(domain, extension string, data []byte) error { + var baseFileName string + if s.filename != "" { + baseFileName = s.filename + } else { + baseFileName = sanitizedDomain(domain) } - for _, oldFile := range matches { - if strings.TrimSuffix(oldFile, filepath.Ext(oldFile)) != baseFilename && oldFile != baseFilename+issuerExt { - continue - } + filePath := filepath.Join(s.rootPath, baseFileName+extension) - date := strconv.FormatInt(time.Now().Unix(), 10) - filename := date + "." + filepath.Base(oldFile) - newFile := filepath.Join(s.archivePath, filename) + log.Info("Writing file.", + slog.String("filepath", filePath)) - err = os.Rename(oldFile, newFile) - if err != nil { - return err - } - } - - return nil + return os.WriteFile(filePath, data, filePerm) } func getCertificateChain(certRes *certificate.Resource) ([]*x509.Certificate, error) { @@ -339,16 +299,3 @@ func getPFXEncoder(pfxFormat string) (*pkcs12.Encoder, error) { return encoder, nil } - -// sanitizedDomain Make sure no funny chars are in the cert names (like wildcards ;)). -func sanitizedDomain(domain string) string { - safe, err := idna.ToASCII(strings.NewReplacer(":", "-", "*", "_").Replace(domain)) - if err != nil { - log.Fatal("Could not sanitize the domain.", - log.DomainAttr(domain), - log.ErrorAttr(err), - ) - } - - return safe -} diff --git a/cmd/internal/storage/certificates_writer_test.go b/cmd/internal/storage/certificates_writer_test.go new file mode 100644 index 000000000..36db47fee --- /dev/null +++ b/cmd/internal/storage/certificates_writer_test.go @@ -0,0 +1,285 @@ +package storage + +import ( + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/go-acme/lego/v5/certificate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCertificatesWriter_CreateRootFolder(t *testing.T) { + writer, err := NewCertificatesWriter(CertificatesWriterConfig{ + BasePath: t.TempDir(), + }) + require.NoError(t, err) + + require.NoDirExists(t, writer.rootPath) + + writer.CreateRootFolder() + + require.DirExists(t, writer.rootPath) +} + +func TestCertificatesWriter_CreateArchiveFolder(t *testing.T) { + writer, err := NewCertificatesWriter(CertificatesWriterConfig{ + BasePath: t.TempDir(), + }) + require.NoError(t, err) + + require.NoDirExists(t, writer.GetArchivePath()) + + writer.CreateArchiveFolder() + + require.DirExists(t, writer.GetArchivePath()) +} + +func TestCertificatesWriter_SaveResource(t *testing.T) { + basePath := t.TempDir() + + writer, err := NewCertificatesWriter(CertificatesWriterConfig{ + BasePath: basePath, + }) + require.NoError(t, err) + + err = os.MkdirAll(writer.rootPath, 0o755) + require.NoError(t, err) + + require.NoFileExists(t, filepath.Join(basePath, baseCertificatesFolderName, "example.com.crt")) + require.NoFileExists(t, filepath.Join(basePath, baseCertificatesFolderName, "example.com.issuer")) + require.NoFileExists(t, filepath.Join(basePath, baseCertificatesFolderName, "example.com.key")) + require.NoFileExists(t, filepath.Join(basePath, baseCertificatesFolderName, "example.com.json")) + + writer.SaveResource(&certificate.Resource{ + Domain: "example.com", + CertURL: "https://acme.example.org/cert/123", + CertStableURL: "https://acme.example.org/cert/456", + PrivateKey: []byte("PrivateKey"), + Certificate: []byte("Certificate"), + IssuerCertificate: []byte("IssuerCertificate"), + CSR: []byte("CSR"), + }) + + require.FileExists(t, filepath.Join(basePath, baseCertificatesFolderName, "example.com.crt")) + require.FileExists(t, filepath.Join(basePath, baseCertificatesFolderName, "example.com.issuer.crt")) + require.FileExists(t, filepath.Join(basePath, baseCertificatesFolderName, "example.com.key")) + require.FileExists(t, filepath.Join(basePath, baseCertificatesFolderName, "example.com.json")) + + assertCertificateFileContent(t, basePath, "example.com.crt") + assertCertificateFileContent(t, basePath, "example.com.issuer.crt") + assertCertificateFileContent(t, basePath, "example.com.key") + + actual, err := os.ReadFile(filepath.Join(basePath, baseCertificatesFolderName, "example.com.json")) + require.NoError(t, err) + + expected, err := os.ReadFile(filepath.Join("testdata", baseCertificatesFolderName, "example.com.json")) + require.NoError(t, err) + + assert.JSONEq(t, string(expected), string(actual)) +} + +func TestCertificatesWriter_MoveToArchive(t *testing.T) { + domain := "example.com" + + certStorage := setupCertificatesWriter(t) + + domainFiles := generateTestFiles(t, certStorage.rootPath, domain) + + err := certStorage.MoveToArchive(domain) + require.NoError(t, err) + + for _, file := range domainFiles { + assert.NoFileExists(t, file) + } + + root, err := os.ReadDir(certStorage.rootPath) + require.NoError(t, err) + require.Empty(t, root) + + archive, err := os.ReadDir(certStorage.GetArchivePath()) + require.NoError(t, err) + + require.Len(t, archive, len(domainFiles)) + assert.Regexp(t, `\d+\.`+regexp.QuoteMeta(domain), archive[0].Name()) +} + +func TestCertificatesWriter_MoveToArchive_noFileRelatedToDomain(t *testing.T) { + domain := "example.com" + + certStorage := setupCertificatesWriter(t) + + domainFiles := generateTestFiles(t, certStorage.rootPath, "example.org") + + err := certStorage.MoveToArchive(domain) + require.NoError(t, err) + + for _, file := range domainFiles { + assert.FileExists(t, file) + } + + root, err := os.ReadDir(certStorage.rootPath) + require.NoError(t, err) + assert.Len(t, root, len(domainFiles)) + + archive, err := os.ReadDir(certStorage.GetArchivePath()) + require.NoError(t, err) + + assert.Empty(t, archive) +} + +func TestCertificatesWriter_MoveToArchive_ambiguousDomain(t *testing.T) { + domain := "example.com" + + certStorage := setupCertificatesWriter(t) + + domainFiles := generateTestFiles(t, certStorage.rootPath, domain) + otherDomainFiles := generateTestFiles(t, certStorage.rootPath, domain+".example.org") + + err := certStorage.MoveToArchive(domain) + require.NoError(t, err) + + for _, file := range domainFiles { + assert.NoFileExists(t, file) + } + + for _, file := range otherDomainFiles { + assert.FileExists(t, file) + } + + root, err := os.ReadDir(certStorage.rootPath) + require.NoError(t, err) + require.Len(t, root, len(otherDomainFiles)) + + archive, err := os.ReadDir(certStorage.GetArchivePath()) + require.NoError(t, err) + + require.Len(t, archive, len(domainFiles)) + assert.Regexp(t, `\d+\.`+regexp.QuoteMeta(domain), archive[0].Name()) +} + +func TestCertificatesWriter_GetArchivePath(t *testing.T) { + basePath := t.TempDir() + + writer, err := NewCertificatesWriter(CertificatesWriterConfig{ + BasePath: basePath, + }) + require.NoError(t, err) + + assert.Equal(t, filepath.Join(basePath, baseArchivesFolderName), writer.GetArchivePath()) +} + +func TestCertificatesWriter_IsPEM(t *testing.T) { + testCases := []struct { + desc string + pem bool + assert assert.BoolAssertionFunc + }{ + { + desc: "PEM enable", + pem: true, + assert: assert.True, + }, + { + desc: "PEM disable", + pem: false, + assert: assert.False, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + writer, err := NewCertificatesWriter(CertificatesWriterConfig{ + BasePath: t.TempDir(), + PEM: test.pem, + }) + require.NoError(t, err) + + test.assert(t, writer.IsPEM()) + }) + } +} + +func TestCertificatesWriter_IsPFX(t *testing.T) { + testCases := []struct { + desc string + pfx bool + assert assert.BoolAssertionFunc + }{ + { + desc: "PFX enable", + pfx: true, + assert: assert.True, + }, + { + desc: "PFX disable", + pfx: false, + assert: assert.False, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + writer, err := NewCertificatesWriter(CertificatesWriterConfig{ + BasePath: t.TempDir(), + PFX: test.pfx, + PFXFormat: "DES", + }) + require.NoError(t, err) + + test.assert(t, writer.IsPFX()) + }) + } +} + +func assertCertificateFileContent(t *testing.T, basePath, filename string) { + t.Helper() + + actual, err := os.ReadFile(filepath.Join(basePath, baseCertificatesFolderName, filename)) + require.NoError(t, err) + + expected, err := os.ReadFile(filepath.Join("testdata", baseCertificatesFolderName, filename)) + require.NoError(t, err) + + assert.Equal(t, string(expected), string(actual)) +} + +func setupCertificatesWriter(t *testing.T) *CertificatesWriter { + t.Helper() + + basePath := t.TempDir() + + writer, err := NewCertificatesWriter( + CertificatesWriterConfig{ + BasePath: basePath, + }, + ) + require.NoError(t, err) + + writer.CreateRootFolder() + writer.CreateArchiveFolder() + + return writer +} + +func generateTestFiles(t *testing.T, dir, domain string) []string { + t.Helper() + + var filenames []string + + for _, ext := range []string{ExtIssuer, ExtCert, ExtKey, ExtPEM, ExtPFX, ExtResource} { + filename := filepath.Join(dir, domain+ext) + err := os.WriteFile(filename, []byte("test"), 0o666) + require.NoError(t, err) + + filenames = append(filenames, filename) + } + + return filenames +} diff --git a/cmd/internal/storage/testdata/account.json b/cmd/internal/storage/testdata/account.json new file mode 100644 index 000000000..295743fa2 --- /dev/null +++ b/cmd/internal/storage/testdata/account.json @@ -0,0 +1,16 @@ +{ + "email": "account@example.com", + "registration": { + "body": { + "status": "valid", + "contact": [ + "contact@example.com" + ], + "termsOfServiceAgreed": true, + "orders": "https://ame.example.com/orders/123456", + "onlyReturnExisting": true, + "externalAccountBinding": "EAB" + }, + "uri": "https://ame.example.com" + } +} diff --git a/cmd/internal/storage/testdata/accounts/test@example.com/keys/test@example.com.key b/cmd/internal/storage/testdata/accounts/test@example.com/keys/test@example.com.key new file mode 100644 index 000000000..987023568 --- /dev/null +++ b/cmd/internal/storage/testdata/accounts/test@example.com/keys/test@example.com.key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJJwIBAAKCAgEAxF6apvKKtm33tZbeYwsS2v3BAR6Q1UM4PzyTnckyjlqQ+9p6 +IX3ZTDfatAw4D7Xzw3LQ8YwsNBJPRmx3SFtcbpWsuRtFFtnNuPlxw4UcM2QhWaq1 +55R6TMn8dcD+TgQWX5yG3dJBAAHNwtsxbGHjlafu6DHqFrhE63O7Pa0yV/Ld7lyg +CrbIfEEroKPZxXco5VyxH+HceAhBqYe2j7ACWy5G3VlFS2zM3tor5u15bO7XGJAv +Q1jiIG05TWxm0cfSq6IiJ3vaWlvwEmHFBxSmB/6/IGblw0FKLf2lUOYma87xJeS4 +2TjPYw+cZRqSYJeUBpXGuoGZq600PvpNwoPlT9umxFfAIOSLcKFzEqMG73sJVgO6 +TgDl+YYdkWQ0iCBmanUkkUUw11ESO3jIk5Wx7MSljbN21FIhMXOjzlY73Hx7YrlE +Vz9IVARXp1cnr0rZtGx/WfIt8JXwt2FGiXsjb4BkVBfq7unofpgT1QBajYdu9sCO +z1C26ctgByXldsHH/7UdS9O/5zMA/WJeHzYxC8GIMt/LSxOOH98FAvDCdnTE8r1b +GJb0vEwPUNbjA1rzmx62K/4WsS/aCnxGsAhGFePky/NsuXOqGAXEtbWYhWR6IjJg +RELa28RNwT61x67xuc0bz0H0/aaFZ0brIR6td4igyAOb6TxvbT7Esmm3Ua0CAwEA +AQKCAf8TS1+/VmRKw5cJaASZQJD8LpA+ZIdgbP9U6Isia/E899CCKaabVREEzp+H +OL/TQYOyEwwl6sdZaiRf7m/J+wi3hiTBRnaDZc+CIjtt00PyF4Ibr9G5oKIU6XUM +REy4cV+aBWTE3Qs3MAngQuRdOAX9OEfesByLMqOS9Rrm4M7qJBpH20gtJ8iLMdwX +iilRx4ruYN5o9MhSZWmYF1sniBAVqmKumCqZUlKAMKDkmavefl4t9/zUPsAglumU +T3TeimFQLSfy4Jr2ReruWZk9e1PBTffIXqKq7zWCFEKz60uzwj8qoWJNML30wrtM +bu183vHAKgr6SF8zfyAFhkPO7LCbNvApOp+DhaOHzL9fx+T0toFnlgXlQ8YWQAjI +Cc6Z8uyKK+R6p4YdhNEB0SUYP8uNNtgmwzhxzf63A5H+XC2r43uveepgN1XtgKbf +GuHKwye7FcKs5L+OTMlUEaWXl0FXp1cRUUAxiOdqQggP6UBFfiuCSId5dl9P7+hY +FdE7By/3bPK5exdBz5bLcVBowrK67bNfwzFBTZAPhrYQENe45zl0FpWiBEWBHTqY +EDxoOqXgzPtrfarT+1MbfOBEQDhZarEc1i8HgUeXUpdC8MR074sN+knMeBBsRw84 +YwVMXPhPgvJbo12cmnW434aKaw6VXLLfE+uO1F/Q3QI2zNAVAoIBAQDLdaqR3gL/ +HV4MM3o++1UmN7KZdrCQS9WLRR479/2nSfA3WvCyZW6+qg330RJaDkEO1vyyaaRA +c7EIfHy8Ej8Lc9pde0m/1Xl1fZ2p/Tw9e31gAglBHsQlPtrahbTQCwpDXQR5oQwm +pk6tfBD83g+hxqTj0a6N/uL8IkviruhI8wniqD690cyPxCDRNdeY3Kf1RGOBatZH +VB83f7n6aSrtP0vJreWPx1Nr4LGjS7WcLI7fMeXu+uB45pXOGKbg1B47HQNAArQF +732SCOa0E4YpoweDJjIjI/8JSLehqlM+Dfr8RlC2SrjkSPS+vQqLzrZJe4iUqAMd +2yBuYD5lQmuzAoIBAQD3FDm8VkBucjxPF3bUZS5gkD8PAzcWBbrU0RVBkA+yZQJF +6iZb9nY6owVkzVhtTHM/ejV+cNp6zpMd4MiGZ+yc3OUmskoBNeQIj8WxPYDZc9Bl +b1BMdEmy4wz4haZoic8SEfnbPmxh+Rt6y08qkucPWswTJu6+A+kK2cr+Pk2AUkZ7 +pNgugytLjmFVVaDisgmm/8B6RnFSYuz1yBkgzcBqULNtayUUrTIQTcaAej7Vs/NT +umVB5B3gdIGMr1xpu7Z2QgErTzOnpg2rCBDW80Dhl8o6OG6f74u/gSUrbJrPPGYl +5bKGpijj9D3SYXXfxa+sOZMgcCs1+PwbxL39Ix0fAoIBAQDHH2XMVMgB/i/pKQhf +U4NGYAR/hVXQIyffocmxT0gEzaw3wN0I+5SjLbN18jxPvtuVNnsh8Zo7Kf79GzjI +p+LjxoLUMrE++iJhBoujrp+iXJWbvOJpxT6aZSWz8F/BrMximUqj7yYBPYqK532I +vZv27H68KJ75gZeMw9QZCq1zl8j490hQZmAZ5A1qM5PJm0sWE9R++Jy2OnJC0tKl +bQACKYx22aZuTNosHkA8XQBk1IcPkbpDZW0DZkj+58mLCI59tCtWHk8p7/WpUuTX +ILSRU2kqxdsT4UrdPznZEuVdOjmFZRvhDMhfQ7ekZUdJBQoKaMiHFNfYBHl9DNyE +JblZAoIBAEZL9NlG2PITgmEmVeK8HuPOZoKI8aVMYAmoqxmKOU0SPAFUSzGi/6RT +OXeijOQb+jY3OP0Ocrq6B1Va3PKQottGZdQKqc+KW6Sr8x0oWH6F2ubhMsFt3IIO +42PZ8qyPeOC1SJc+PWvqigz3x0Bfp2nQ9XsFequJRUaXDJAlfbtirTcEgZVKMIlA +qySyRiH5cZGX9lVTsW41QyHymmOg5nvZFhOthlFJrZLB7hYjsbjvh+1sfN6wXme7 +/hfe1LYoeBNRWC/QSuwJ7J2an9/oOa91lk3WPHM4nlQQFFk0fx8zGgTyQ0bYA45H +sUcn/3d5MTAY+WkjQMgMXP4xjbR1xxUCggEAYzuIlT+zxIocmIFtilzQG5fWCU3Z +SYYuP9tRyoRHvTc9N0BojIe34GoQhsqMhlu2u7NlDIDbSQvHtjmi33J0x3Y2uUYy +64CcsrNUnN8tOka8jdcshDOhxsUKo08u/+ZNKpTzD4GfgUKvbz4WcZAg8PJTBSjQ +XFkboKvTBh926THByobwRL/spPAZTVCydtq8G9cVxaaMq1OQJn2KSPZ/dsmWvCBp +zEKwt7g4fFVlQCekJ2tB95LwkyKhjaiqEefyViGM5rtBc4n7KEDKn2L2n+5jBLvD +Gb7FwCYpVTtf3futX2xruSie98VhTrDjDB6sSP3iQ9knayk2Okx7o3PoeQ== +-----END RSA PRIVATE KEY----- diff --git a/cmd/internal/storage/testdata/certificates/example.com.crt b/cmd/internal/storage/testdata/certificates/example.com.crt new file mode 100644 index 000000000..9f017b85f --- /dev/null +++ b/cmd/internal/storage/testdata/certificates/example.com.crt @@ -0,0 +1 @@ +Certificate \ No newline at end of file diff --git a/cmd/internal/storage/testdata/certificates/example.com.issuer.crt b/cmd/internal/storage/testdata/certificates/example.com.issuer.crt new file mode 100644 index 000000000..4077a254d --- /dev/null +++ b/cmd/internal/storage/testdata/certificates/example.com.issuer.crt @@ -0,0 +1 @@ +IssuerCertificate \ No newline at end of file diff --git a/cmd/internal/storage/testdata/certificates/example.com.json b/cmd/internal/storage/testdata/certificates/example.com.json new file mode 100644 index 000000000..22660a25d --- /dev/null +++ b/cmd/internal/storage/testdata/certificates/example.com.json @@ -0,0 +1,5 @@ +{ + "domain": "example.com", + "certUrl": "https://acme.example.org/cert/123", + "certStableUrl": "https://acme.example.org/cert/456" +} \ No newline at end of file diff --git a/cmd/internal/storage/testdata/certificates/example.com.key b/cmd/internal/storage/testdata/certificates/example.com.key new file mode 100644 index 000000000..a18453e00 --- /dev/null +++ b/cmd/internal/storage/testdata/certificates/example.com.key @@ -0,0 +1 @@ +PrivateKey \ No newline at end of file diff --git a/cmd/internal/storage/testdata/certificates/example.org.crt b/cmd/internal/storage/testdata/certificates/example.org.crt new file mode 100644 index 000000000..b9350f6be --- /dev/null +++ b/cmd/internal/storage/testdata/certificates/example.org.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBkzCCATmgAwIBAgIQZrrrYlA0GzvmKXjrMwLQNDAKBggqhkjOPQQDAjASMRAw +DgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYwMDAw +WjASMRAwDgYDVQQKEwdBY21lIENvMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +NkulPSW9ljKP9q4IRK1lEAp/AvNeWHOcpWgy9c76XkUm05aulJk3Zra1VewY3dq4 +XjMPIe/YVwmriOjaw1uuE6NvMG0wDgYDVR0PAQH/BAQDAgKEMBMGA1UdJQQMMAoG +CCsGAQUFBwMBMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFKh2BJ5EzUfChYGB +wwhWa+8sTLALMBYGA1UdEQQPMA2CC2V4YW1wbGUub3JnMAoGCCqGSM49BAMCA0gA +MEUCIBbwUdYyeJTRQJBxY8s4IUAr7KhyT2+/WuusIy3+FpsTAiEA+7X2Vikcrerk +XWwaukHVTQhirl0SHh2QegsQzM9IzTM= +-----END CERTIFICATE----- diff --git a/cmd/lego/main.go b/cmd/lego/main.go index e5bd8b785..38d5d3a2c 100644 --- a/cmd/lego/main.go +++ b/cmd/lego/main.go @@ -19,8 +19,6 @@ func main() { Usage: "Let's Encrypt client written in Go", Version: getVersion(), EnableShellCompletion: true, - Flags: cmd.CreateFlags(""), - Before: cmd.Before, Commands: cmd.CreateCommands(), } @@ -28,8 +26,6 @@ func main() { fmt.Printf("lego version %s %s/%s\n", cmd.Version, runtime.GOOS, runtime.GOARCH) } - app.Commands = cmd.CreateCommands() - err := app.Run(context.Background(), os.Args) if err != nil { log.Fatal("Error", log.ErrorAttr(err)) diff --git a/cmd/setup.go b/cmd/setup.go index c70effdff..75e8c1de2 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -15,6 +15,7 @@ import ( "github.com/go-acme/lego/v5/acme" "github.com/go-acme/lego/v5/certcrypto" + "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/go-acme/lego/v5/registration" @@ -22,10 +23,8 @@ import ( "github.com/urfave/cli/v3" ) -const filePerm os.FileMode = 0o600 - // setupClient creates a new client with challenge settings. -func setupClient(cmd *cli.Command, account *Account, keyType certcrypto.KeyType) *lego.Client { +func setupClient(cmd *cli.Command, account *storage.Account, keyType certcrypto.KeyType) *lego.Client { client := newClient(cmd, account, keyType) setupChallenges(cmd, client) @@ -33,15 +32,15 @@ func setupClient(cmd *cli.Command, account *Account, keyType certcrypto.KeyType) return client } -func setupAccount(ctx context.Context, cmd *cli.Command, accountsStorage *AccountsStorage) (*Account, certcrypto.KeyType) { +func setupAccount(ctx context.Context, cmd *cli.Command, accountsStorage *storage.AccountsStorage) (*storage.Account, certcrypto.KeyType) { keyType := getKeyType(cmd) privateKey := accountsStorage.GetPrivateKey(keyType) - var account *Account + var account *storage.Account if accountsStorage.ExistsAccountFilePath() { account = accountsStorage.LoadAccount(ctx, privateKey) } else { - account = &Account{Email: accountsStorage.GetEmail(), key: privateKey} + account = storage.NewAccount(accountsStorage.GetEmail(), privateKey) } return account, keyType @@ -123,16 +122,6 @@ func getUserAgent(cmd *cli.Command) string { return strings.TrimSpace(fmt.Sprintf("%s lego-cli/%s", cmd.String(flgUserAgent), cmd.Version)) } -func createNonExistingFolder(path string) error { - if _, err := os.Stat(path); os.IsNotExist(err) { - return os.MkdirAll(path, 0o700) - } else if err != nil { - return err - } - - return nil -} - func readCSRFile(filename string) (*x509.CertificateRequest, error) { bytes, err := os.ReadFile(filename) if err != nil { diff --git a/cmd/storages.go b/cmd/storages.go new file mode 100644 index 000000000..a6b9c2189 --- /dev/null +++ b/cmd/storages.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "github.com/go-acme/lego/v5/cmd/internal/storage" + "github.com/go-acme/lego/v5/log" + "github.com/urfave/cli/v3" +) + +// CertificatesStorage a certificates' storage. +type CertificatesStorage struct { + *storage.CertificatesWriter + *storage.CertificatesReader +} + +// newCertificatesStorage create a new certificates storage. +func newCertificatesStorage(cmd *cli.Command) *CertificatesStorage { + basePath := cmd.String(flgPath) + + config := storage.CertificatesWriterConfig{ + BasePath: basePath, + PEM: cmd.Bool(flgPEM), + PFX: cmd.Bool(flgPFX), + PFXFormat: cmd.String(flgPFXPass), + PFXPassword: cmd.String(flgPFXFormat), + Filename: cmd.String(flgFilename), + } + + writer, err := storage.NewCertificatesWriter(config) + if err != nil { + log.Fatal("Certificates storage initialization", log.ErrorAttr(err)) + } + + return &CertificatesStorage{ + CertificatesWriter: writer, + CertificatesReader: storage.NewCertificatesReader(basePath), + } +} + +// newAccountsStorage Creates a new AccountsStorage. +func newAccountsStorage(cmd *cli.Command) *storage.AccountsStorage { + accountsStorage, err := storage.NewAccountsStorage(storage.AccountsStorageConfig{ + Email: cmd.String(flgEmail), + BasePath: cmd.String(flgPath), + Server: cmd.String(flgServer), + UserAgent: getUserAgent(cmd), + }) + if err != nil { + log.Fatal("Accounts storage initialization", log.ErrorAttr(err)) + } + + return accountsStorage +} diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index fd0015cb2..ceb287d3b 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -19,18 +19,40 @@ COMMANDS: help, h Shows a list of commands or help for one command GLOBAL OPTIONS: + --help, -h show help +""" + +[[command]] +title = "lego help run" +content = """ +NAME: + lego run - Register an account, then create and install a certificate + +USAGE: + lego run + +OPTIONS: --domains string, -d string [ --domains string, -d string ] Add a domain to the process. Can be specified multiple times. - --server string, -s string CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. (default: "https://acme-v02.api.letsencrypt.org/directory") [$LEGO_SERVER] --accept-tos, -a By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service. --email string, -m string Email used for registration and recovery contact. [$LEGO_EMAIL] - --disable-cn Disable the use of the common name in the CSR. --csr string, -c string Certificate signing request filename, if an external CSR is to be used. --eab Use External Account Binding for account registration. Requires --kid and --hmac. [$LEGO_EAB] --kid string Key identifier from External CA. Used for External Account Binding. [$LEGO_EAB_KID] --hmac string MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding. [$LEGO_EAB_HMAC] + --server string, -s string CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. [$LEGO_SERVER] + --disable-cn Disable the use of the common name in the CSR. --key-type string, -k string Key type to use for private keys. Supported: rsa2048, rsa3072, rsa4096, rsa8192, ec256, ec384. (default: "ec256") + --http-timeout int Set the HTTP timeout value to a specific value in seconds. (default: 0) + --tls-skip-verify Skip the TLS verification of the ACME server. + --cert.timeout int Set the certificate timeout value to a specific value in seconds. Only used when obtaining certificates. (default: 30) + --overall-request-limit int ACME overall requests limit. (default: 18) + --user-agent string Add to the user-agent sent to the CA to identify an application embedding lego-cli --filename string (deprecated) Filename of the generated certificate. - --path string Directory to use for storing the data. (default: "./.lego") [$LEGO_PATH] + --path string Directory to use for storing the data. [$LEGO_PATH] + --pem Generate an additional .pem (base64) file by concatenating the .key and .crt files together. + --pfx Generate an additional .pfx (PKCS#12) file by concatenating the .key and .crt and issuer .crt files together. [$LEGO_PFX] + --pfx.pass string The password used to encrypt the .pfx (PCKS#12) file. (default: "changeit") [$LEGO_PFX_PASSWORD] + --pfx.format string The encoding format to use when encrypting the .pfx (PCKS#12) file. Supported: RC2, DES, SHA256. (default: "RC2") [$LEGO_PFX_FORMAT] --http Use the HTTP-01 challenge to solve challenges. Can be mixed with other types of challenges. --http.port string Set the port and interface to use for HTTP-01 based challenges to listen on. Supported: interface:port or :port. (default: ":80") --http.delay duration Delay between the starts of the HTTP server (use for HTTP-01 based challenges) and the validation of the challenge. (default: 0s) @@ -47,42 +69,20 @@ GLOBAL OPTIONS: --dns.propagation-rns By setting this flag to true, use all the recursive nameservers to check the propagation of the TXT record. --dns.propagation-wait duration By setting this flag, disables all the propagation checks of the TXT record and uses a wait duration instead. (default: 0s) --dns.resolvers string [ --dns.resolvers string ] Set the resolvers to use for performing (recursive) CNAME resolving and apex domain determination. For DNS-01 challenge verification, the authoritative DNS server is queried directly. Supported: host:port. The default is to use the system resolvers, or Google's DNS resolvers if the system's cannot be determined. - --http-timeout int Set the HTTP timeout value to a specific value in seconds. (default: 0) - --tls-skip-verify Skip the TLS verification of the ACME server. --dns-timeout int Set the DNS timeout value to a specific value in seconds. Used only when performing authoritative name server queries. (default: 10) - --pem Generate an additional .pem (base64) file by concatenating the .key and .crt files together. - --pfx Generate an additional .pfx (PKCS#12) file by concatenating the .key and .crt and issuer .crt files together. [$LEGO_PFX] - --pfx.pass string The password used to encrypt the .pfx (PCKS#12) file. (default: "changeit") [$LEGO_PFX_PASSWORD] - --pfx.format string The encoding format to use when encrypting the .pfx (PCKS#12) file. Supported: RC2, DES, SHA256. (default: "RC2") [$LEGO_PFX_FORMAT] - --cert.timeout int Set the certificate timeout value to a specific value in seconds. Only used when obtaining certificates. (default: 30) - --overall-request-limit int ACME overall requests limit. (default: 18) - --user-agent string Add to the user-agent sent to the CA to identify an application embedding lego-cli + --no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate. + --must-staple Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego. + --not-before time Set the notBefore field in the certificate (RFC3339 format) + --not-after time Set the notAfter field in the certificate (RFC3339 format) + --private-key string Path to private key (in PEM encoding) for the certificate. By default, the private key is generated. + --preferred-chain string If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used. + --profile string If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one. + --always-deactivate-authorizations string Force the authorizations to be relinquished even if the certificate request was successful. + --run-hook string Define a hook. The hook is executed when the certificates are effectively created. + --run-hook-timeout duration Define the timeout for the hook execution. (default: 2m0s) --help, -h show help """ -[[command]] -title = "lego help run" -content = """ -NAME: - lego run - Register an account, then create and install a certificate - -USAGE: - lego run - -OPTIONS: - --no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate. - --must-staple Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego. - --not-before time Set the notBefore field in the certificate (RFC3339 format) - --not-after time Set the notAfter field in the certificate (RFC3339 format) - --private-key string Path to private key (in PEM encoding) for the certificate. By default, the private key is generated. - --preferred-chain string If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used. - --profile string If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one. - --always-deactivate-authorizations string Force the authorizations to be relinquished even if the certificate request was successful. - --run-hook string Define a hook. The hook is executed when the certificates are effectively created. - --run-hook-timeout duration Define the timeout for the hook execution. (default: 2m0s) - --help, -h show help -""" - [[command]] title = "lego help renew" content = """ @@ -93,23 +93,61 @@ USAGE: lego renew OPTIONS: - --days int The number of days left on a certificate to renew it. (default: 30) - --dynamic Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5. - --ari-disable Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed. - --ari-wait-to-renew-duration duration The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint. (default: 0s) - --reuse-key Used to indicate you want to reuse your current private key for the new certificate. - --no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate. - --must-staple Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego. - --not-before time Set the notBefore field in the certificate (RFC3339 format) - --not-after time Set the notAfter field in the certificate (RFC3339 format) - --preferred-chain string If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used. - --profile string If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one. - --always-deactivate-authorizations string Force the authorizations to be relinquished even if the certificate request was successful. - --renew-hook string Define a hook. The hook is executed only when the certificates are effectively renewed. - --renew-hook-timeout duration Define the timeout for the hook execution. (default: 2m0s) - --no-random-sleep Do not add a random sleep before the renewal. We do not recommend using this flag if you are doing your renewals in an automated way. - --force-cert-domains Check and ensure that the cert's domain list matches those passed in the domains argument. - --help, -h show help + --domains string, -d string [ --domains string, -d string ] Add a domain to the process. Can be specified multiple times. + --accept-tos, -a By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service. + --email string, -m string Email used for registration and recovery contact. [$LEGO_EMAIL] + --csr string, -c string Certificate signing request filename, if an external CSR is to be used. + --eab Use External Account Binding for account registration. Requires --kid and --hmac. [$LEGO_EAB] + --kid string Key identifier from External CA. Used for External Account Binding. [$LEGO_EAB_KID] + --hmac string MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding. [$LEGO_EAB_HMAC] + --server string, -s string CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. [$LEGO_SERVER] + --disable-cn Disable the use of the common name in the CSR. + --key-type string, -k string Key type to use for private keys. Supported: rsa2048, rsa3072, rsa4096, rsa8192, ec256, ec384. (default: "ec256") + --http-timeout int Set the HTTP timeout value to a specific value in seconds. (default: 0) + --tls-skip-verify Skip the TLS verification of the ACME server. + --cert.timeout int Set the certificate timeout value to a specific value in seconds. Only used when obtaining certificates. (default: 30) + --overall-request-limit int ACME overall requests limit. (default: 18) + --user-agent string Add to the user-agent sent to the CA to identify an application embedding lego-cli + --filename string (deprecated) Filename of the generated certificate. + --path string Directory to use for storing the data. [$LEGO_PATH] + --pem Generate an additional .pem (base64) file by concatenating the .key and .crt files together. + --pfx Generate an additional .pfx (PKCS#12) file by concatenating the .key and .crt and issuer .crt files together. [$LEGO_PFX] + --pfx.pass string The password used to encrypt the .pfx (PCKS#12) file. (default: "changeit") [$LEGO_PFX_PASSWORD] + --pfx.format string The encoding format to use when encrypting the .pfx (PCKS#12) file. Supported: RC2, DES, SHA256. (default: "RC2") [$LEGO_PFX_FORMAT] + --http Use the HTTP-01 challenge to solve challenges. Can be mixed with other types of challenges. + --http.port string Set the port and interface to use for HTTP-01 based challenges to listen on. Supported: interface:port or :port. (default: ":80") + --http.delay duration Delay between the starts of the HTTP server (use for HTTP-01 based challenges) and the validation of the challenge. (default: 0s) + --http.proxy-header string Validate against this HTTP header when solving HTTP-01 based challenges behind a reverse proxy. (default: "Host") + --http.webroot string Set the webroot folder to use for HTTP-01 based challenges to write directly to the .well-known/acme-challenge file. This disables the built-in server and expects the given directory to be publicly served with access to .well-known/acme-challenge + --http.memcached-host string [ --http.memcached-host string ] Set the memcached host(s) to use for HTTP-01 based challenges. Challenges will be written to all specified hosts. + --http.s3-bucket string Set the S3 bucket name to use for HTTP-01 based challenges. Challenges will be written to the S3 bucket. + --tls Use the TLS-ALPN-01 challenge to solve challenges. Can be mixed with other types of challenges. + --tls.port string Set the port and interface to use for TLS-ALPN-01 based challenges to listen on. Supported: interface:port or :port. (default: ":443") + --tls.delay duration Delay between the start of the TLS listener (use for TLSALPN-01 based challenges) and the validation of the challenge. (default: 0s) + --dns string Solve a DNS-01 challenge using the specified provider. Can be mixed with other types of challenges. Run 'lego dnshelp' for help on usage. + --dns.disable-cp (deprecated) use dns.propagation-disable-ans instead. + --dns.propagation-disable-ans By setting this flag to true, disables the need to await propagation of the TXT record to all authoritative name servers. + --dns.propagation-rns By setting this flag to true, use all the recursive nameservers to check the propagation of the TXT record. + --dns.propagation-wait duration By setting this flag, disables all the propagation checks of the TXT record and uses a wait duration instead. (default: 0s) + --dns.resolvers string [ --dns.resolvers string ] Set the resolvers to use for performing (recursive) CNAME resolving and apex domain determination. For DNS-01 challenge verification, the authoritative DNS server is queried directly. Supported: host:port. The default is to use the system resolvers, or Google's DNS resolvers if the system's cannot be determined. + --dns-timeout int Set the DNS timeout value to a specific value in seconds. Used only when performing authoritative name server queries. (default: 10) + --days int The number of days left on a certificate to renew it. (default: 30) + --dynamic Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5. + --ari-disable Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed. + --ari-wait-to-renew-duration duration The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint. (default: 0s) + --reuse-key Used to indicate you want to reuse your current private key for the new certificate. + --no-bundle Do not create a certificate bundle by adding the issuers certificate to the new certificate. + --must-staple Include the OCSP must staple TLS extension in the CSR and generated certificate. Only works if the CSR is generated by lego. + --not-before time Set the notBefore field in the certificate (RFC3339 format) + --not-after time Set the notAfter field in the certificate (RFC3339 format) + --preferred-chain string If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used. + --profile string If the CA offers multiple certificate profiles (draft-ietf-acme-profiles), choose this one. + --always-deactivate-authorizations string Force the authorizations to be relinquished even if the certificate request was successful. + --renew-hook string Define a hook. The hook is executed only when the certificates are effectively renewed. + --renew-hook-timeout duration Define the timeout for the hook execution. (default: 2m0s) + --no-random-sleep Do not add a random sleep before the renewal. We do not recommend using this flag if you are doing your renewals in an automated way. + --force-cert-domains Check and ensure that the cert's domain list matches those passed in the domains argument. + --help, -h show help """ [[command]] @@ -122,9 +160,47 @@ USAGE: lego revoke OPTIONS: - --keep, -k Keep the certificates after the revocation instead of archiving them. - --reason uint Identifies the reason for the certificate revocation. See https://www.rfc-editor.org/rfc/rfc5280.html#section-5.3.1. Valid values are: 0 (unspecified), 1 (keyCompromise), 2 (cACompromise), 3 (affiliationChanged), 4 (superseded), 5 (cessationOfOperation), 6 (certificateHold), 8 (removeFromCRL), 9 (privilegeWithdrawn), or 10 (aACompromise). (default: 0) - --help, -h show help + --domains string, -d string [ --domains string, -d string ] Add a domain to the process. Can be specified multiple times. + --accept-tos, -a By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service. + --email string, -m string Email used for registration and recovery contact. [$LEGO_EMAIL] + --csr string, -c string Certificate signing request filename, if an external CSR is to be used. + --eab Use External Account Binding for account registration. Requires --kid and --hmac. [$LEGO_EAB] + --kid string Key identifier from External CA. Used for External Account Binding. [$LEGO_EAB_KID] + --hmac string MAC key from External CA. Should be in Base64 URL Encoding without padding format. Used for External Account Binding. [$LEGO_EAB_HMAC] + --server string, -s string CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. [$LEGO_SERVER] + --disable-cn Disable the use of the common name in the CSR. + --key-type string, -k string Key type to use for private keys. Supported: rsa2048, rsa3072, rsa4096, rsa8192, ec256, ec384. (default: "ec256") + --http-timeout int Set the HTTP timeout value to a specific value in seconds. (default: 0) + --tls-skip-verify Skip the TLS verification of the ACME server. + --cert.timeout int Set the certificate timeout value to a specific value in seconds. Only used when obtaining certificates. (default: 30) + --overall-request-limit int ACME overall requests limit. (default: 18) + --user-agent string Add to the user-agent sent to the CA to identify an application embedding lego-cli + --filename string (deprecated) Filename of the generated certificate. + --path string Directory to use for storing the data. [$LEGO_PATH] + --pem Generate an additional .pem (base64) file by concatenating the .key and .crt files together. + --pfx Generate an additional .pfx (PKCS#12) file by concatenating the .key and .crt and issuer .crt files together. [$LEGO_PFX] + --pfx.pass string The password used to encrypt the .pfx (PCKS#12) file. (default: "changeit") [$LEGO_PFX_PASSWORD] + --pfx.format string The encoding format to use when encrypting the .pfx (PCKS#12) file. Supported: RC2, DES, SHA256. (default: "RC2") [$LEGO_PFX_FORMAT] + --http Use the HTTP-01 challenge to solve challenges. Can be mixed with other types of challenges. + --http.port string Set the port and interface to use for HTTP-01 based challenges to listen on. Supported: interface:port or :port. (default: ":80") + --http.delay duration Delay between the starts of the HTTP server (use for HTTP-01 based challenges) and the validation of the challenge. (default: 0s) + --http.proxy-header string Validate against this HTTP header when solving HTTP-01 based challenges behind a reverse proxy. (default: "Host") + --http.webroot string Set the webroot folder to use for HTTP-01 based challenges to write directly to the .well-known/acme-challenge file. This disables the built-in server and expects the given directory to be publicly served with access to .well-known/acme-challenge + --http.memcached-host string [ --http.memcached-host string ] Set the memcached host(s) to use for HTTP-01 based challenges. Challenges will be written to all specified hosts. + --http.s3-bucket string Set the S3 bucket name to use for HTTP-01 based challenges. Challenges will be written to the S3 bucket. + --tls Use the TLS-ALPN-01 challenge to solve challenges. Can be mixed with other types of challenges. + --tls.port string Set the port and interface to use for TLS-ALPN-01 based challenges to listen on. Supported: interface:port or :port. (default: ":443") + --tls.delay duration Delay between the start of the TLS listener (use for TLSALPN-01 based challenges) and the validation of the challenge. (default: 0s) + --dns string Solve a DNS-01 challenge using the specified provider. Can be mixed with other types of challenges. Run 'lego dnshelp' for help on usage. + --dns.disable-cp (deprecated) use dns.propagation-disable-ans instead. + --dns.propagation-disable-ans By setting this flag to true, disables the need to await propagation of the TXT record to all authoritative name servers. + --dns.propagation-rns By setting this flag to true, use all the recursive nameservers to check the propagation of the TXT record. + --dns.propagation-wait duration By setting this flag, disables all the propagation checks of the TXT record and uses a wait duration instead. (default: 0s) + --dns.resolvers string [ --dns.resolvers string ] Set the resolvers to use for performing (recursive) CNAME resolving and apex domain determination. For DNS-01 challenge verification, the authoritative DNS server is queried directly. Supported: host:port. The default is to use the system resolvers, or Google's DNS resolvers if the system's cannot be determined. + --dns-timeout int Set the DNS timeout value to a specific value in seconds. Used only when performing authoritative name server queries. (default: 10) + --keep, -k Keep the certificates after the revocation instead of archiving them. + --reason uint Identifies the reason for the certificate revocation. See https://www.rfc-editor.org/rfc/rfc5280.html#section-5.3.1. Valid values are: 0 (unspecified), 1 (keyCompromise), 2 (cACompromise), 3 (affiliationChanged), 4 (superseded), 5 (cessationOfOperation), 6 (certificateHold), 8 (removeFromCRL), 9 (privilegeWithdrawn), or 10 (aACompromise). (default: 0) + --help, -h show help """ [[command]] @@ -138,7 +214,8 @@ USAGE: OPTIONS: --accounts, -a Display accounts. - --names, -n Display certificate common names only. + --names, -n Display certificate names only. + --path string Directory to use for storing the data. [$LEGO_PATH] --help, -h show help """ diff --git a/e2e/challenges_test.go b/e2e/challenges_test.go index 30e1abe52..51a27a937 100644 --- a/e2e/challenges_test.go +++ b/e2e/challenges_test.go @@ -66,13 +66,14 @@ func TestChallengeHTTP_Run(t *testing.T) { loader.CleanLegoFiles(t.Context()) err := load.RunLego(t.Context(), + "run", "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", testDomain1, "--http", "--http.port", ":5002", - "run") + ) if err != nil { t.Fatal(err) } @@ -82,13 +83,14 @@ func TestChallengeTLS_Run_Domains(t *testing.T) { loader.CleanLegoFiles(t.Context()) err := load.RunLego(t.Context(), + "run", "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", testDomain1, "--tls", "--tls.port", ":5001", - "run") + ) if err != nil { t.Fatal(err) } @@ -98,13 +100,14 @@ func TestChallengeTLS_Run_IP(t *testing.T) { loader.CleanLegoFiles(t.Context()) err := load.RunLego(t.Context(), + "run", "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", "127.0.0.1", "--tls", "--tls.port", ":5001", - "run") + ) if err != nil { t.Fatal(err) } @@ -116,13 +119,14 @@ func TestChallengeTLS_Run_CSR(t *testing.T) { csrPath := createTestCSRFile(t, true) err := load.RunLego(t.Context(), + "run", "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-csr", csrPath, "--tls", "--tls.port", ":5001", - "run") + ) if err != nil { t.Fatal(err) } @@ -134,13 +138,14 @@ func TestChallengeTLS_Run_CSR_PEM(t *testing.T) { csrPath := createTestCSRFile(t, false) err := load.RunLego(t.Context(), + "run", "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-csr", csrPath, "--tls", "--tls.port", ":5001", - "run") + ) if err != nil { t.Fatal(err) } @@ -150,6 +155,7 @@ func TestChallengeTLS_Run_Revoke(t *testing.T) { loader.CleanLegoFiles(t.Context()) err := load.RunLego(t.Context(), + "run", "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", @@ -157,12 +163,13 @@ func TestChallengeTLS_Run_Revoke(t *testing.T) { "-d", testDomain3, "--tls", "--tls.port", ":5001", - "run") + ) if err != nil { t.Fatal(err) } err = load.RunLego(t.Context(), + "revoke", "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", @@ -179,25 +186,27 @@ func TestChallengeTLS_Run_Revoke_Non_ASCII(t *testing.T) { loader.CleanLegoFiles(t.Context()) err := load.RunLego(t.Context(), + "run", "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", testDomain4, "--tls", "--tls.port", ":5001", - "run") + ) if err != nil { t.Fatal(err) } err = load.RunLego(t.Context(), + "revoke", "-m", testEmail1, "--accept-tos", "-s", "https://localhost:14000/dir", "-d", testDomain4, "--tls", "--tls.port", ":5001", - "revoke") + ) if err != nil { t.Fatal(err) } diff --git a/e2e/dnschallenge/dns_challenges_test.go b/e2e/dnschallenge/dns_challenges_test.go index 19b4baf5a..d40e66a09 100644 --- a/e2e/dnschallenge/dns_challenges_test.go +++ b/e2e/dnschallenge/dns_challenges_test.go @@ -59,6 +59,7 @@ func TestChallengeDNS_Run(t *testing.T) { loader.CleanLegoFiles(t.Context()) err := load.RunLego(t.Context(), + "run", "--accept-tos", "--dns", "exec", "--dns.resolvers", ":8053", @@ -66,7 +67,7 @@ func TestChallengeDNS_Run(t *testing.T) { "-s", "https://localhost:15000/dir", "-d", testDomain2, "-d", testDomain1, - "run") + ) if err != nil { t.Fatal(err) } diff --git a/internal/clihelp/generator.go b/internal/clihelp/generator.go index 40ac9d652..e50ff083a 100644 --- a/internal/clihelp/generator.go +++ b/internal/clihelp/generator.go @@ -86,14 +86,12 @@ func generate(ctx context.Context) error { // createStubApp Construct cli app, very similar to cmd/lego/main.go. // Notable differences: -// - substitute "." for CWD in default config path, as the user will very likely see a different path // - do not include version information, because we're likely running against a snapshot // - skip DNS help and provider list, as initialization takes time, and we don't generate `lego dns --help` here. func createStubApp() *cli.Command { return &cli.Command{ Name: "lego", Usage: "Let's Encrypt client written in Go", - Flags: cmd.CreateFlags("./.lego"), Commands: cmd.CreateCommands(), } }