From 37e754b40c2456aed397ffa1292a019c1b8a66e5 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Fri, 21 Feb 2020 14:32:57 -0700 Subject: [PATCH] Major refactor to improve performance, correctness, and extensibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking changes; thank goodness we're not 1.0 yet 😅 - read on! This change completely separates ACME-specific code from the rest of the certificate management process, allowing pluggable sources for certs that aren't ACME. Notably, most of Config was spliced into ACMEManager. Similarly, there's now Default and DefaultACME. Storage structure had to be reconfigured. Certificates are no longer in the acme/ subfolder since they can be obtained by ways other than ACME! Certificates moved to a new certificates/ subfolder. The subfolders in that folder use the path of the ACME endpoint instead of just the host, so that also changed. Be aware that unless you move your certs over, CertMagic will not find them and will attempt to get new ones. That is usually fine for most users, but for extremely large deployments, you will want to move them over first. Old certs path: acme/acme-staging-v02.api.letsencrypt.org/... New certs path: certificates/acme-staging-v02.api.letsencrypt.org-directory/... That's all for significant storage changes! But this refactor also vastly improves performance, especially at scale, and makes CertMagic way more resilient to errors. Retries are done on the staging endpoint by default, so they won't count against your rate limit. If your hardware can handle it, I'm now pretty confident that you can give CertMagic a million domain names and it will gracefully manage them, as fast as it can within internal and external rate limits, even in the presence of errors. Errors will of course slow some things down, but you should be good to go if you're monitoring logs and can fix any misconfigurations or other external errors! Several other mostly-minor enhancements fix bugs, especially at scale. For example, duplicated renewal tasks (that continuously fail) will not pile up on each other: only one will operate, under exponential backoff. Closes #50 and fixes #55 --- README.md | 41 +-- acmeclient.go | 388 ++++++++++++++++++++++++++ acmemanager.go | 339 +++++++++++++++++++++++ acmemanager_test.go | 3 + async.go | 153 ++++++++++ certificates.go | 25 +- certmagic.go | 121 ++++++-- client.go | 659 -------------------------------------------- config.go | 586 +++++++++++++++++++++++++-------------- config_test.go | 30 +- crypto.go | 141 ++++++++-- filestorage.go | 2 +- go.mod | 2 +- go.sum | 11 +- handshake.go | 19 +- httphandler.go | 28 +- httphandler_test.go | 17 +- maintain.go | 62 +++-- solvers.go | 44 ++- storage.go | 120 +++----- storage_test.go | 53 ++-- user.go | 122 +++++--- user_test.go | 104 ++++--- 23 files changed, 1870 insertions(+), 1200 deletions(-) create mode 100644 acmeclient.go create mode 100644 acmemanager.go create mode 100644 acmemanager_test.go create mode 100644 async.go delete mode 100644 client.go diff --git a/README.md b/README.md index 87683b66..da9930af 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@

-Caddy's automagic TLS features, now for your own Go programs, in one powerful and easy-to-use library! +Caddy's automagic TLS features—now for your own Go programs—in one powerful and easy-to-use library! -CertMagic is the most mature, robust, and capable ACME client integration for Go. +CertMagic is the most mature, robust, and capable ACME client integration for Go... and perhaps ever. With CertMagic, you can add one line to your Go application to serve securely over TLS, without ever having to touch certificates. @@ -142,23 +142,25 @@ The `certmagic.Config` struct is how you can wield the power of this fully armed The default `Config` value is called `certmagic.Default`. Change its fields to suit your needs, then call `certmagic.NewDefault()` when you need a valid `Config` value. In other words, `certmagic.Default` is a template and is not valid for use directly. -You can set the default values easily, for example: `certmagic.Default.Email = ...`. +You can set the default values easily, for example: `certmagic.Default.Issuer = ...`. + +Similarly, to configure ACME-specific defaults, use `certmagic.DefaultACME`. The high-level functions in this package (`HTTPS()`, `Listen()`, `ManageSync()`, and `ManageAsync()`) use the default config exclusively. This is how most of you will interact with the package. This is suitable when all your certificates are managed the same way. However, if you need to manage certificates differently depending on their name, you will need to make your own cache and configs (keep reading). #### Providing an email address -Although not strictly required, this is highly recommended best practice. It allows you to receive expiration emails if your certificates are expiring for some reason, and also allows the CA's engineers to potentially get in touch with you if something is wrong. I recommend setting `certmagic.Default.Email` or always setting the `Email` field of a new `Config` struct. +Although not strictly required, this is highly recommended best practice. It allows you to receive expiration emails if your certificates are expiring for some reason, and also allows the CA's engineers to potentially get in touch with you if something is wrong. I recommend setting `certmagic.DefaultACME.Email` or always setting the `Email` field of a new `Config` struct. #### Rate limiting -To avoid firehosing the CA's servers, CertMagic has built-in rate limiting. Currently, its default limit is up to 10 transactions (obtain or renew) every 1 minute (sliding window). This can be changed by setting the `RateLimitOrders` and `RateLimitOrdersWindow` variables, if desired. +To avoid firehosing the CA's servers, CertMagic has built-in rate limiting. Currently, its default limit is up to 10 transactions (obtain or renew) every 1 minute (sliding window). This can be changed by setting the `RateLimitEvents` and `RateLimitEventsWindow` variables, if desired. The CA may still enforce their own rate limits, and there's nothing (well, nothing ethical) CertMagic can do to bypass them for you. -Additionally, CertMagic will retry failed validations with exponential backoff for up to 30 days, with a maximum of 1 day between attempts. (An "attempt" means trying each enabled challenge type twice.) +Additionally, CertMagic will retry failed validations with exponential backoff for up to 30 days, with a reasonable maximum interval between attempts (an "attempt" means trying each enabled challenge type once). ### Development and Testing @@ -167,7 +169,7 @@ Note that Let's Encrypt imposes [strict rate limits](https://letsencrypt.org/doc While developing your application and testing it, use [their staging endpoint](https://letsencrypt.org/docs/staging-environment/) which has much higher rate limits. Even then, don't hammer it: but it's much safer for when you're testing. When deploying, though, use their production CA because their staging CA doesn't issue trusted certificates. -To use staging, set `certmagic.Default.CA = certmagic.LetsEncryptStagingCA` or set `CA` of every `Config` struct. +To use staging, set `certmagic.DefaultACME.CA = certmagic.LetsEncryptStagingCA` or set `CA` of every `ACMEManager` struct. @@ -175,19 +177,19 @@ To use staging, set `certmagic.Default.CA = certmagic.LetsEncryptStagingCA` or s There are many ways to use this library. We'll start with the highest-level (simplest) and work down (more control). -All these high-level examples use `certmagic.Default` for the config and the default cache and storage for serving up certificates. +All these high-level examples use `certmagic.Default` and `certmagic.DefaultACME` for the config and the default cache and storage for serving up certificates. First, we'll follow best practices and do the following: ```go // read and agree to your CA's legal documents -certmagic.Default.Agreed = true +certmagic.DefaultACME.Agreed = true // provide an email address -certmagic.Default.Email = "you@yours.com" +certmagic.DefaultACME.Email = "you@yours.com" // use the staging endpoint while we're developing -certmagic.Default.CA = certmagic.LetsEncryptStagingCA +certmagic.DefaultACME.CA = certmagic.LetsEncryptStagingCA ``` For fully-functional program examples, check out [this Twitter thread](https://twitter.com/mholt6/status/1073103805112147968) (or read it [unrolled into a single post](https://threadreaderapp.com/thread/1073103805112147968.html)). (Note that the package API has changed slightly since these posts.) @@ -244,12 +246,18 @@ cache := certmagic.NewCache(certmagic.CacheOptions{ }) magic := certmagic.New(cache, certmagic.Config{ + // any customizations you need go here +}) + +myACME := certmagic.NewACMEManager(magic, ACMEManager{ CA: certmagic.LetsEncryptStagingCA, Email: "you@yours.com", Agreed: true, - // plus any other customization you want + // plus any other customizations you need }) +magic.Issuer = myACME + // this obtains certificates or renews them if necessary err := magic.ManageSync([]string{"example.com", "sub.example.com"}) if err != nil { @@ -271,7 +279,7 @@ myTLSConfig.NextProtos = append(myTLSConfig.NextProtos, tlsalpn01.ACMETLS1Protoc // the HTTP challenge has to be handled by your HTTP server; // if you don't have one, you should have disabled it earlier // when you made the certmagic.Config -httpMux = magic.HTTPChallengeHandler(httpMux) +httpMux = myACME.HTTPChallengeHandler(httpMux) ``` Great! This example grants you much more flexibility for advanced programs. However, _the vast majority of you will only use the high-level functions described earlier_, especially since you can still customize them by setting the package-level `Default` config. @@ -318,16 +326,17 @@ mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { fmt.Fprintf(w, "Lookit my cool website over HTTPS!") }) -http.ListenAndServe(":80", magic.HTTPChallengeHandler(mux)) +http.ListenAndServe(":80", myACME.HTTPChallengeHandler(mux)) ``` If wrapping your handler is not a good solution, try this inside your `ServeHTTP()` instead: ```go magic := certmagic.NewDefault() +myACME := certmagic.NewACMEManager(magic, certmagic.DefaultACME) func ServeHTTP(w http.ResponseWriter, req *http.Request) { - if magic.HandleHTTPChallenge(w, r) { + if myACME.HandleHTTPChallenge(w, r) { return // challenge handled; nothing else to do } ... @@ -378,7 +387,7 @@ if err != nil { return err } -certmagic.Default.DNSProvider = provider +certmagic.DefaultACME.DNSProvider = provider ``` Now the DNS challenge will be used by default, and I can obtain certificates for wildcard domains. See the [godoc documentation for the provider you're using](https://godoc.org/github.com/go-acme/lego/providers/dns#pkg-subdirectories) to learn how to configure it. Most can be configured by env variables or by passing in a config struct. If you pass a config struct instead of using env variables, you will probably need to set some other defaults (that's just how lego works, currently): diff --git a/acmeclient.go b/acmeclient.go new file mode 100644 index 00000000..61d8f689 --- /dev/null +++ b/acmeclient.go @@ -0,0 +1,388 @@ +// Copyright 2015 Matthew Holt +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package certmagic + +import ( + "context" + "crypto/tls" + "fmt" + "log" + weakrand "math/rand" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-acme/lego/v3/acme" + "github.com/go-acme/lego/v3/certificate" + "github.com/go-acme/lego/v3/challenge" + "github.com/go-acme/lego/v3/lego" + "github.com/go-acme/lego/v3/registration" +) + +func init() { + weakrand.Seed(time.Now().UnixNano()) +} + +// acmeClient is a wrapper over lego's acme.Client with +// some custom state attached. It is used to obtain, +// renew, and revoke certificates with ACME. Use +// ACMEManager.newACMEClient() or +// ACMEManager.newACMEClientWithRetry() to get a valid +// one for real use. +type acmeClient struct { + caURL string + mgr *ACMEManager + acmeClient *lego.Client + challenges []challenge.Type +} + +// newACMEClientWithRetry is the same as newACMEClient, but with +// automatic retry capabilities. Sometimes network connections or +// HTTP requests fail intermittently, even when requesting the +// directory endpoint for example, so we can avoid that by just +// retrying once. Failures here are rare and sporadic, usually, +// so a simple retry is an easy fix. +func (am *ACMEManager) newACMEClientWithRetry(useTestCA bool) (*acmeClient, error) { + var client *acmeClient + var err error + const maxTries = 2 + for i := 0; i < maxTries; i++ { + client, err = am.newACMEClient(useTestCA, false) // TODO: move logic that requires interactivity to way before this part of the process... + if err == nil { + break + } + if acmeErr, ok := err.(acme.ProblemDetails); ok { + if acmeErr.HTTPStatus == http.StatusTooManyRequests { + return nil, fmt.Errorf("too many requests making new ACME client: %+v - aborting", acmeErr) + } + } + log.Printf("[ERROR] Making new ACME client: %v (attempt %d/%d)", err, i+1, maxTries) + time.Sleep(1 * time.Second) + } + return client, err +} + +// newACMEClient creates the underlying ACME library client type. +// If useTestCA is true, am.TestCA will be used if it is set; +// otherwise, the primary CA will still be used. +func (am *ACMEManager) newACMEClient(useTestCA, interactive bool) (*acmeClient, error) { + acmeClientsMu.Lock() + defer acmeClientsMu.Unlock() + + // ensure defaults are filled in + certObtainTimeout := am.CertObtainTimeout + if certObtainTimeout == 0 { + certObtainTimeout = DefaultACME.CertObtainTimeout + } + var caURL string + if useTestCA { + caURL = am.TestCA + // only use the default test CA if the CA is also + // the default CA; no point in testing against + // Let's Encrypt's staging server if we are not + // using their production server too + if caURL == "" && am.CA == DefaultACME.CA { + caURL = DefaultACME.TestCA + } + } + if caURL == "" { + caURL = am.CA + } + if caURL == "" { + caURL = DefaultACME.CA + } + + // ensure endpoint is secure (assume HTTPS if scheme is missing) + if !strings.Contains(caURL, "://") { + caURL = "https://" + caURL + } + u, err := url.Parse(caURL) + if err != nil { + return nil, err + } + if u.Scheme != "https" && !isLoopback(u.Host) && !isInternal(u.Host) { + return nil, fmt.Errorf("%s: insecure CA URL (HTTPS required)", caURL) + } + + // look up or create the user account + leUser, err := am.getUser(caURL, am.Email) + if err != nil { + return nil, err + } + + // if a lego client with this configuration already exists, reuse it + clientKey := caURL + leUser.Email + client, ok := acmeClients[clientKey] + if !ok { + // the client facilitates our communication with the CA server + legoCfg := lego.NewConfig(leUser) + legoCfg.CADirURL = caURL + legoCfg.UserAgent = buildUAString() + legoCfg.HTTPClient.Timeout = HTTPTimeout + legoCfg.Certificate = lego.CertificateConfig{ + Timeout: am.CertObtainTimeout, + } + if am.TrustedRoots != nil { + if ht, ok := legoCfg.HTTPClient.Transport.(*http.Transport); ok { + if ht.TLSClientConfig == nil { + ht.TLSClientConfig = new(tls.Config) + ht.ForceAttemptHTTP2 = true + } + ht.TLSClientConfig.RootCAs = am.TrustedRoots + } + } + client, err = lego.NewClient(legoCfg) + if err != nil { + return nil, err + } + acmeClients[clientKey] = client + } + + // if not registered, the user must register an account + // with the CA and agree to terms + if leUser.Registration == nil { + if interactive { // can't prompt a user who isn't there + termsURL := client.GetToSURL() + if !am.Agreed && termsURL != "" { + am.Agreed = am.askUserAgreement(client.GetToSURL()) + } + if !am.Agreed && termsURL != "" { + return nil, fmt.Errorf("user must agree to CA terms") + } + } + + reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: am.Agreed}) + if err != nil { + return nil, err + } + leUser.Registration = reg + + // persist the user to storage + err = am.saveUser(caURL, leUser) + if err != nil { + return nil, fmt.Errorf("could not save user: %v", err) + } + } + + c := &acmeClient{ + caURL: caURL, + mgr: am, + acmeClient: client, + } + + return c, nil +} + +// initialChallenges returns the initial set of challenges +// to try using c.config as a basis. +func (c *acmeClient) initialChallenges() []challenge.Type { + // if configured, use DNS challenge exclusively + if c.mgr.DNSProvider != nil { + return []challenge.Type{challenge.DNS01} + } + + // otherwise, use HTTP and TLS-ALPN challenges if enabled + var chal []challenge.Type + if !c.mgr.DisableHTTPChallenge { + chal = append(chal, challenge.HTTP01) + } + if !c.mgr.DisableTLSALPNChallenge { + chal = append(chal, challenge.TLSALPN01) + } + return chal +} + +// nextChallenge chooses a challenge randomly from the given list of +// available challenges and configures c.acmeClient to use that challenge +// according to c.config. It pops the chosen challenge from the list and +// returns that challenge along with the new list without that challenge. +// If len(available) == 0, this is a no-op. +// +// Don't even get me started on how dumb it is we need to do this here +// instead of the upstream lego library doing it for us. Lego used to +// randomize the challenge order, thus allowing another one to be used +// if the first one failed. https://github.com/go-acme/lego/issues/842 +// (It also has an awkward API for adjusting the available challenges.) +// At time of writing, lego doesn't try anything other than the TLS-ALPN +// challenge, even if the HTTP challenge is also enabled. So we take +// matters into our own hands and enable only one challenge at a time +// in the underlying client, randomly selected by us. +func (c *acmeClient) nextChallenge(available []challenge.Type) (challenge.Type, []challenge.Type) { + if len(available) == 0 { + return "", available + } + + // make sure we choose a challenge randomly, which lego used to do but + // the critical feature was surreptitiously removed in ~2018 in a commit + // too large to review, oh well - choose one, then remove it from the + // list of available challenges so it doesn't get retried + randIdx := weakrand.Intn(len(available)) + randomChallenge := available[randIdx] + available = append(available[:randIdx], available[randIdx+1:]...) + + // clean the slate, since we reuse clients + c.acmeClient.Challenge.Remove(challenge.HTTP01) + c.acmeClient.Challenge.Remove(challenge.TLSALPN01) + c.acmeClient.Challenge.Remove(challenge.DNS01) + + switch randomChallenge { + case challenge.HTTP01: + useHTTPPort := HTTPChallengePort + if HTTPPort > 0 && HTTPPort != HTTPChallengePort { + useHTTPPort = HTTPPort + } + if c.mgr.AltHTTPPort > 0 { + useHTTPPort = c.mgr.AltHTTPPort + } + + c.acmeClient.Challenge.SetHTTP01Provider(distributedSolver{ + acmeManager: c.mgr, + providerServer: &httpSolver{ + acmeManager: c.mgr, + address: net.JoinHostPort(c.mgr.ListenHost, strconv.Itoa(useHTTPPort)), + }, + caURL: c.caURL, + }) + + case challenge.TLSALPN01: + useTLSALPNPort := TLSALPNChallengePort + if HTTPSPort > 0 && HTTPSPort != TLSALPNChallengePort { + useTLSALPNPort = HTTPSPort + } + if c.mgr.AltTLSALPNPort > 0 { + useTLSALPNPort = c.mgr.AltTLSALPNPort + } + + c.acmeClient.Challenge.SetTLSALPN01Provider(distributedSolver{ + acmeManager: c.mgr, + providerServer: &tlsALPNSolver{ + config: c.mgr.config, + address: net.JoinHostPort(c.mgr.ListenHost, strconv.Itoa(useTLSALPNPort)), + }, + caURL: c.caURL, + }) + + case challenge.DNS01: + if c.mgr.DNSChallengeOption != nil { + c.acmeClient.Challenge.SetDNS01Provider(c.mgr.DNSProvider, c.mgr.DNSChallengeOption) + } else { + c.acmeClient.Challenge.SetDNS01Provider(c.mgr.DNSProvider) + } + } + + return randomChallenge, available +} + +func (c *acmeClient) throttle(ctx context.Context, names []string) error { + // throttling is scoped to CA + account email + rateLimiterKey := c.mgr.CA + "," + c.mgr.Email + rateLimitersMu.Lock() + rl, ok := rateLimiters[rateLimiterKey] + if !ok { + rl = NewRateLimiter(RateLimitEvents, RateLimitEventsWindow) + rateLimiters[rateLimiterKey] = rl + // TODO: stop rate limiter when it is garbage-collected... + } + rateLimitersMu.Unlock() + log.Printf("[INFO]%v Waiting on rate limiter...", names) + err := rl.Wait(ctx) + if err != nil { + return err + } + log.Printf("[INFO]%v Done waiting", names) + return nil +} + +func (c *acmeClient) usingTestCA() bool { + return c.mgr.TestCA != "" && c.caURL == c.mgr.TestCA +} + +func (c *acmeClient) revoke(_ context.Context, certRes certificate.Resource) error { + return c.acmeClient.Certificate.Revoke(certRes.Certificate) +} + +func buildUAString() string { + ua := "CertMagic" + if UserAgent != "" { + ua += " " + UserAgent + } + return ua +} + +// These internal rate limits are designed to prevent accidentally +// firehosing a CA's ACME endpoints. They are not intended to +// replace or replicate the CA's actual rate limits. +// +// Let's Encrypt's rate limits can be found here: +// https://letsencrypt.org/docs/rate-limits/ +// +// Currently (as of December 2019), Let's Encrypt's most relevant +// rate limit for large deployments is 300 new orders per account +// per 3 hours (on average, or best case, that's about 1 every 36 +// seconds, or 2 every 72 seconds, etc.); but it's not reasonable +// to try to assume that our internal state is the same as the CA's +// (due to process restarts, config changes, failed validations, +// etc.) and ultimately, only the CA's actual rate limiter is the +// authority. Thus, our own rate limiters do not attempt to enforce +// external rate limits. Doing so causes problems when the domains +// are not in our control (i.e. serving customer sites) and/or lots +// of domains fail validation: they clog our internal rate limiter +// and nearly starve out (or at least slow down) the other domains +// that need certificates. Failed transactions are already retried +// with exponential backoff, so adding in rate limiting can slow +// things down even more. +// +// Instead, the point of our internal rate limiter is to avoid +// hammering the CA's endpoint when there are thousands or even +// millions of certificates under management. Our goal is to +// allow small bursts in a relatively short timeframe so as to +// not block any one domain for too long, without unleashing +// thousands of requests to the CA at once. +var ( + rateLimiters = make(map[string]*RingBufferRateLimiter) + rateLimitersMu sync.RWMutex + + // RateLimitEvents is how many new events can be allowed + // in RateLimitEventsWindow. + RateLimitEvents = 10 + + // RateLimitEventsWindow is the size of the sliding + // window that throttles events. + RateLimitEventsWindow = 1 * time.Minute +) + +// Some default values passed down to the underlying lego client. +var ( + UserAgent string + HTTPTimeout = 30 * time.Second +) + +// We keep a global cache of ACME clients so that they +// can be reused. Since the number of CAs, accounts, +// and key types should be fairly limited under best +// practices, this map will hardly ever have more than +// a few entries at the most. The associated lock +// protects access to the map but also ensures that only +// one ACME client is created at a time. +// TODO: consider using storage for a distributed lock +// TODO: consider evicting clients after some time +var ( + acmeClients = make(map[string]*lego.Client) + acmeClientsMu sync.Mutex +) diff --git a/acmemanager.go b/acmemanager.go new file mode 100644 index 00000000..e613df51 --- /dev/null +++ b/acmemanager.go @@ -0,0 +1,339 @@ +package certmagic + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-acme/lego/v3/acme" + "github.com/go-acme/lego/v3/certificate" + "github.com/go-acme/lego/v3/challenge" + "github.com/go-acme/lego/v3/challenge/dns01" +) + +// ACMEManager gets certificates using ACME. It implements the PreChecker, +// Issuer, and Revoker interfaces. +// +// It is NOT VALID to use an ACMEManager without calling NewACMEManager(). +// It fills in default values from DefaultACME as well as setting up +// internal state that is necessary for valid use. Always call +// NewACMEManager() to get a valid ACMEManager value. +type ACMEManager struct { + // The endpoint of the directory for the ACME + // CA we are to use + CA string + + // TestCA is the endpoint of the directory for + // an ACME CA to use to test domain validation, + // but any certs obtained from this CA are + // discarded + TestCA string + + // The email address to use when creating or + // selecting an existing ACME server account + Email string + + // Set to true if agreed to the CA's + // subscriber agreement + Agreed bool + + // Disable all HTTP challenges + DisableHTTPChallenge bool + + // Disable all TLS-ALPN challenges + DisableTLSALPNChallenge bool + + // The host (ONLY the host, not port) to listen + // on if necessary to start a listener to solve + // an ACME challenge + ListenHost string + + // The alternate port to use for the ACME HTTP + // challenge; if non-empty, this port will be + // used instead of HTTPChallengePort to spin up + // a listener for the HTTP challenge + AltHTTPPort int + + // The alternate port to use for the ACME + // TLS-ALPN challenge; the system must forward + // TLSALPNChallengePort to this port for + // challenge to succeed + AltTLSALPNPort int + + // The DNS provider to use when solving the + // ACME DNS challenge + DNSProvider challenge.Provider + + // The ChallengeOption struct to provide + // custom precheck or name resolution options + // for DNS challenge validation and execution + DNSChallengeOption dns01.ChallengeOption + + // TrustedRoots specifies a pool of root CA + // certificates to trust when communicating + // over a network to a peer. + TrustedRoots *x509.CertPool + + // The maximum amount of time to allow for + // obtaining a certificate. If empty, the + // default from the underlying lego lib is + // used. If set, it must not be too low so + // as to cancel orders too early, running + // the risk of rate limiting. + CertObtainTimeout time.Duration + + config *Config +} + +// NewACMEManager constructs a valid ACMEManager based on a template +// configuration; any empty values will be filled in by defaults in +// DefaultACME. The associated config is also required. +func NewACMEManager(cfg *Config, template ACMEManager) *ACMEManager { + if cfg == nil { + panic("cannot make valid ACMEManager without an associated CertMagic config") + } + if template.CA == "" { + template.CA = DefaultACME.CA + } + if template.TestCA == "" { + template.TestCA = DefaultACME.TestCA + } + if template.Email == "" { + template.Email = DefaultACME.Email + } + if !template.Agreed { + template.Agreed = DefaultACME.Agreed + } + if !template.DisableHTTPChallenge { + template.DisableHTTPChallenge = DefaultACME.DisableHTTPChallenge + } + if !template.DisableTLSALPNChallenge { + template.DisableTLSALPNChallenge = DefaultACME.DisableTLSALPNChallenge + } + if template.ListenHost == "" { + template.ListenHost = DefaultACME.ListenHost + } + if template.AltHTTPPort == 0 { + template.AltHTTPPort = DefaultACME.AltHTTPPort + } + if template.AltTLSALPNPort == 0 { + template.AltTLSALPNPort = DefaultACME.AltTLSALPNPort + } + if template.DNSProvider == nil { + template.DNSProvider = DefaultACME.DNSProvider + } + if template.DNSChallengeOption == nil { + template.DNSChallengeOption = DefaultACME.DNSChallengeOption + } + if template.TrustedRoots == nil { + template.TrustedRoots = DefaultACME.TrustedRoots + } + if template.CertObtainTimeout == 0 { + template.CertObtainTimeout = DefaultACME.CertObtainTimeout + } + template.config = cfg + return &template +} + +// IssuerKey returns the unique issuer key for the +// confgured CA endpoint. +func (am *ACMEManager) IssuerKey() string { + return am.issuerKey(am.CA) +} + +func (am *ACMEManager) issuerKey(ca string) string { + key := ca + if caURL, err := url.Parse(key); err == nil { + key = caURL.Host + if caURL.Path != "" { + // keep the path, but make sure it's a single + // component (i.e. no forward slashes, and for + // good measure, no backward slashes either) + const hyphen = "-" + repl := strings.NewReplacer( + "/", hyphen, + "\\", hyphen, + ) + path := strings.Trim(repl.Replace(caURL.Path), hyphen) + if path != "" { + key += hyphen + path + } + } + } + return key +} + +// PreCheck performs a few simple checks before obtaining or +// renewing a certificate with ACME, and returns whether this +// batch should be skipped as well as any error. It ensures +// that the names qualify for a certificate, and that an email +// address is available. +func (am *ACMEManager) PreCheck(names []string, interactive bool) (skip bool, err error) { + for _, name := range names { + // TODO: move these checks ("skips") into our actual obtain logic, deeper? + if !HostQualifies(name) { + return true, nil + } + } + return false, am.getEmail(interactive) +} + +// Issue implements the Issuer interface. It obtains a certificate for the given csr using +// the ACME configuration am. +func (am *ACMEManager) Issue(ctx context.Context, csr *x509.CertificateRequest) (*IssuedCertificate, error) { + var isRetry bool + if attempts, ok := ctx.Value(AttemptsCtxKey).(*int); ok { + isRetry = *attempts > 0 + } + + cert, usedTestCA, err := am.doIssue(ctx, csr, isRetry) + if err != nil { + return nil, err + } + + // important to note that usedTestCA is not necessarily the same as isRetry + // (usedTestCA can be true if the main CA and the test CA happen to be the same) + if isRetry && usedTestCA { + // succeeded with testing endpoint, so try again with production endpoint + // TODO: This logic is imperfect and could benefit from some refinement. + // The two CA endpoints likely have different states, which could cause one + // to succeed and the other to fail, even if it's not a validation error. + // Two common cases would be: + // 1) Rate limiter state. This is more likely to cause prod to fail while + // staging succeeds, since prod usually has tighter rate limits. Thus, if + // initial attempt failed in prod due to rate limit, first retry (on staging) + // might succeed, and then trying prod again right way would probably still + // fail; normally this would terminate retries but the right thing to do in + // this case is to back off and retry again later. We could refine this logic + // to stick with the production endpoint on retries unless the error changes. + // 2) Cached authorizations state. If a domain validates successfully with + // one endpoint, but then the other endpoint is used, it might fail, e.g. if + // DNS was just changed or is still propagating. In this case, the second CA + // should continue to be retried with backoff, without switching back to the + // other endpoint. This is more likely to happen if a user is testing with + // the staging CA as the main CA, then changes their configuration once they + // think they are ready for the production endpoint. + cert, usedTestCA, err = am.doIssue(ctx, csr, false) + if err != nil { + // succeeded with test CA but failed just now with the production CA; + // either we are observing differing internal states of each CA that will + // work out with time, or there is a bug/misconfiguration somewhere + // externally; it is hard to tell which! one easy cue is whether the + // error is specifically a 429 (Too Many Requests); if so, we should + // probably keep retrying + var acmeErr acme.ProblemDetails + if errors.As(err, &acmeErr) { + if acmeErr.HTTPStatus == http.StatusTooManyRequests { + // DON'T abort retries; the test CA succeeded (even + // if it's cached, it recently succeeded!) so we just + // need to keep trying (with backoff) until this CA's + // rate limits expire... + // TODO: as mentioned in comment above, we would benefit + // by pinning the main CA at this point instead of + // needlessly retrying with the test CA first each time + return nil, err + } + } + return nil, ErrNoRetry{err} + } + } + + return cert, err +} + +func (am *ACMEManager) doIssue(ctx context.Context, csr *x509.CertificateRequest, useTestCA bool) (*IssuedCertificate, bool, error) { + client, err := am.newACMEClientWithRetry(useTestCA) + if err != nil { + return nil, false, err + } + usingTestCA := client.usingTestCA() + + nameSet := namesFromCSR(csr) + + if !useTestCA { + if err := client.throttle(ctx, nameSet); err != nil { + return nil, usingTestCA, err + } + } + + certRes, err := client.tryAllEnabledChallenges(ctx, csr) + if err != nil { + return nil, usingTestCA, fmt.Errorf("%v %w", nameSet, err) + } + + ic := &IssuedCertificate{ + Certificate: certRes.Certificate, + Metadata: certRes, + } + + return ic, usingTestCA, nil +} + +func (c *acmeClient) tryAllEnabledChallenges(ctx context.Context, csr *x509.CertificateRequest) (*certificate.Resource, error) { + // start with all enabled challenges + challenges := c.initialChallenges() + if len(challenges) == 0 { + return nil, fmt.Errorf("no challenge types enabled") + } + + // try while a challenge type is still available + var cert *certificate.Resource + var err error + for len(challenges) > 0 { + var chosenChallenge challenge.Type + chosenChallenge, challenges = c.nextChallenge(challenges) + cert, err = c.acmeClient.Certificate.ObtainForCSR(*csr, true) + if err == nil { + return cert, nil + } + log.Printf("[ERROR] %s (challenge=%s remaining=%v)", err, chosenChallenge, challenges) + time.Sleep(2 * time.Second) + } + return cert, err +} + +// Revoke implements the Revoker interface. It revokes the given certificate. +func (am *ACMEManager) Revoke(ctx context.Context, cert CertificateResource) error { + client, err := am.newACMEClient(false, false) + if err != nil { + return err + } + + meta := cert.IssuerData.(map[string]interface{}) + cr := certificate.Resource{ + Domain: meta["domain"].(string), + CertURL: meta["certUrl"].(string), + CertStableURL: meta["certStableURL"].(string), + } + + return client.revoke(ctx, cr) +} + +// DefaultACME specifies the default settings +// to use for ACMEManagers. +var DefaultACME = ACMEManager{ + CA: LetsEncryptProductionCA, + TestCA: LetsEncryptStagingCA, +} + +// Some well-known CA endpoints available to use. +const ( + LetsEncryptStagingCA = "https://acme-staging-v02.api.letsencrypt.org/directory" + LetsEncryptProductionCA = "https://acme-v02.api.letsencrypt.org/directory" +) + +// prefixACME is the storage key prefix used for ACME-specific assets. +const prefixACME = "acme" + +// Interface guards +var ( + _ PreChecker = (*ACMEManager)(nil) + _ Issuer = (*ACMEManager)(nil) + _ Revoker = (*ACMEManager)(nil) +) diff --git a/acmemanager_test.go b/acmemanager_test.go new file mode 100644 index 00000000..9e0ee187 --- /dev/null +++ b/acmemanager_test.go @@ -0,0 +1,3 @@ +package certmagic + +const dummyCA = "https://example.com/acme/directory" diff --git a/async.go b/async.go new file mode 100644 index 00000000..c9f19d92 --- /dev/null +++ b/async.go @@ -0,0 +1,153 @@ +package certmagic + +import ( + "context" + "errors" + "log" + "sync" + "time" +) + +var jm = &jobManager{maxConcurrentJobs: 1000} + +type jobManager struct { + mu sync.Mutex + maxConcurrentJobs int + activeWorkers int + queue []namedJob + names map[string]struct{} +} + +type namedJob struct { + name string + job func() error +} + +func (jm *jobManager) Submit(name string, job func() error) { + jm.mu.Lock() + defer jm.mu.Unlock() + if jm.names == nil { + jm.names = make(map[string]struct{}) + } + if _, ok := jm.names[name]; ok { + return // prevent duplicate jobs + } + jm.names[name] = struct{}{} + jm.queue = append(jm.queue, namedJob{name, job}) + if jm.activeWorkers < jm.maxConcurrentJobs { + jm.activeWorkers++ + go jm.worker() + } +} + +func (jm *jobManager) worker() { + for { + jm.mu.Lock() + if len(jm.queue) == 0 { + jm.activeWorkers-- + jm.mu.Unlock() + return + } + next := jm.queue[0] + jm.queue = jm.queue[1:] + jm.mu.Unlock() + if err := next.job(); err != nil { + log.Printf("[ERROR] %v", err) + } + jm.mu.Lock() + delete(jm.names, next.name) + jm.mu.Unlock() + } +} + +func doWithRetry(ctx context.Context, f func(context.Context) error) error { + var attempts int + ctx = context.WithValue(ctx, AttemptsCtxKey, &attempts) + + // the initial intervalIndex is -1, signaling + // that we should not wait for the first attempt + start, intervalIndex := time.Now(), -1 + var err error + + for time.Since(start) < maxRetryDuration { + var wait time.Duration + if intervalIndex >= 0 { + wait = retryIntervals[intervalIndex] + } + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return context.Canceled + case <-timer.C: + err = f(ctx) + attempts++ + if err == nil || errors.Is(err, context.Canceled) { + return err + } + var errNoRetry ErrNoRetry + if errors.As(err, &errNoRetry) { + return err + } + if intervalIndex < len(retryIntervals)-1 { + intervalIndex++ + } + if time.Since(start) < maxRetryDuration { + log.Printf("[ERROR] attempt %d: %v - retrying in %s (%s/%s elapsed)...", + attempts, err, retryIntervals[intervalIndex], time.Since(start), maxRetryDuration) + } else { + log.Printf("[ERROR] final attempt: %v - giving up (%s/%s elapsed)...", + err, time.Since(start), maxRetryDuration) + return nil + } + } + } + return err +} + +// ErrNoRetry is an error type which signals +// to stop retries early. +type ErrNoRetry struct{ Err error } + +// Unwrap makes it so that e wraps e.Err. +func (e ErrNoRetry) Unwrap() error { return e.Err } +func (e ErrNoRetry) Error() string { return e.Err.Error() } + +type retryStateCtxKey struct{} + +// AttemptsCtxKey is the context key for the value +// that holds the attempt counter. The value counts +// how many times the operation has been attempted. +// A value of 0 means first attempt. +var AttemptsCtxKey retryStateCtxKey + +// retryIntervals are based on the idea of exponential +// backoff, but weighed a little more heavily to the +// front. We figure that intermittent errors would be +// resolved after the first retry, but any errors after +// that would probably require at least a few minutes +// to clear up: either for DNS to propagate, for the +// administrator to fix their DNS or network properties, +// or some other external factor needs to change. We +// chose intervals that we think will be most useful +// without introducing unnecessary delay. The last +// interval in this list will be used until the time +// of maxRetryDuration has elapsed. +var retryIntervals = []time.Duration{ + 1 * time.Minute, + 2 * time.Minute, + 2 * time.Minute, + 5 * time.Minute, // elapsed: 10 min + 10 * time.Minute, + 20 * time.Minute, + 20 * time.Minute, // elapsed: 1 hr + 30 * time.Minute, + 30 * time.Minute, // elapsed: 2 hr + 1 * time.Hour, + 3 * time.Hour, // elapsed: 6 hr + 6 * time.Hour, // for up to maxRetryDuration +} + +// maxRetryDuration is the maximum duration to try +// doing retries using the above intervals. +const maxRetryDuration = 24 * time.Hour * 30 diff --git a/certificates.go b/certificates.go index f86c2bea..c24cd734 100644 --- a/certificates.go +++ b/certificates.go @@ -109,9 +109,7 @@ func (cfg *Config) CacheManagedCertificate(domain string) (Certificate, error) { return cert, err } cfg.certCache.cacheCertificate(cert) - if cfg.OnEvent != nil { - cfg.OnEvent("cached_managed_cert", cert.Names) - } + cfg.emit("cached_managed_cert", cert.Names) return cert, nil } @@ -122,7 +120,7 @@ func (cfg *Config) loadManagedCertificate(domain string) (Certificate, error) { if err != nil { return Certificate{}, err } - cert, err := makeCertificateWithOCSP(cfg.Storage, certRes.Certificate, certRes.PrivateKey) + cert, err := makeCertificateWithOCSP(cfg.Storage, certRes.CertificatePEM, certRes.PrivateKeyPEM) if err != nil { return cert, err } @@ -142,9 +140,7 @@ func (cfg *Config) CacheUnmanagedCertificatePEMFile(certFile, keyFile string, ta } cert.CertMetadata.Tags = tags cfg.certCache.cacheCertificate(cert) - if cfg.OnEvent != nil { - cfg.OnEvent("cached_unmanaged_cert", cert.Names) - } + cfg.emit("cached_unmanaged_cert", cert.Names) return nil } @@ -162,9 +158,7 @@ func (cfg *Config) CacheUnmanagedTLSCertificate(tlsCert tls.Certificate, tags [] if err != nil { log.Printf("[WARNING] Stapling OCSP: %v", err) } - if cfg.OnEvent != nil { - cfg.OnEvent("cached_unmanaged_cert", cert.Names) - } + cfg.emit("cached_unmanaged_cert", cert.Names) cert.CertMetadata.Tags = tags cfg.certCache.cacheCertificate(cert) return nil @@ -181,9 +175,7 @@ func (cfg *Config) CacheUnmanagedCertificatePEMBytes(certBytes, keyBytes []byte, } cert.CertMetadata.Tags = tags cfg.certCache.cacheCertificate(cert) - if cfg.OnEvent != nil { - cfg.OnEvent("cached_unmanaged_cert", cert.Names) - } + cfg.emit("cached_unmanaged_cert", cert.Names) return nil } @@ -271,6 +263,11 @@ func fillCertFromLeaf(cert *Certificate, tlsCert tls.Certificate) error { cert.Names = append(cert.Names, strings.ToLower(email)) } } + for _, u := range leaf.URIs { + if u.String() != leaf.Subject.CommonName { // TODO: CommonName is deprecated + cert.Names = append(cert.Names, u.String()) + } + } if len(cert.Names) == 0 { return fmt.Errorf("certificate has no names") } @@ -306,7 +303,7 @@ func (cfg *Config) managedCertInStorageExpiresSoon(cert Certificate) (bool, erro if err != nil { return false, err } - tlsCert, err := tls.X509KeyPair(certRes.Certificate, certRes.PrivateKey) + tlsCert, err := tls.X509KeyPair(certRes.CertificatePEM, certRes.PrivateKeyPEM) if err != nil { return false, err } diff --git a/certmagic.go b/certmagic.go index ecb36e60..16790d40 100644 --- a/certmagic.go +++ b/certmagic.go @@ -36,16 +36,17 @@ package certmagic import ( "context" + "crypto" "crypto/tls" + "crypto/x509" "fmt" "log" "net" "net/http" + "sort" "strings" "sync" "time" - - "github.com/go-acme/lego/v3/certcrypto" ) // HTTPS serves mux for all domainNames using the HTTP @@ -69,7 +70,7 @@ func HTTPS(domainNames []string, mux http.Handler) error { mux = http.DefaultServeMux } - Default.Agreed = true + DefaultACME.Agreed = true cfg := NewDefault() err := cfg.ManageSync(domainNames) @@ -120,7 +121,9 @@ func HTTPS(domainNames []string, mux http.Handler) error { ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, IdleTimeout: 5 * time.Second, - Handler: cfg.HTTPChallengeHandler(http.HandlerFunc(httpRedirectHandler)), + } + if am, ok := cfg.Issuer.(*ACMEManager); ok { + httpServer.Handler = am.HTTPChallengeHandler(http.HandlerFunc(httpRedirectHandler)) } httpsServer := &http.Server{ ReadHeaderTimeout: 10 * time.Second, @@ -167,8 +170,8 @@ func httpRedirectHandler(w http.ResponseWriter, r *http.Request) { // Calling this function signifies your acceptance to // the CA's Subscriber Agreement and/or Terms of Service. func TLS(domainNames []string) (*tls.Config, error) { - Default.Agreed = true - Default.DisableHTTPChallenge = true + DefaultACME.Agreed = true + DefaultACME.DisableHTTPChallenge = true cfg := NewDefault() return cfg.TLSConfig(), cfg.ManageSync(domainNames) } @@ -184,8 +187,8 @@ func TLS(domainNames []string) (*tls.Config, error) { // Calling this function signifies your acceptance to // the CA's Subscriber Agreement and/or Terms of Service. func Listen(domainNames []string) (net.Listener, error) { - Default.Agreed = true - Default.DisableHTTPChallenge = true + DefaultACME.Agreed = true + DefaultACME.DisableHTTPChallenge = true cfg := NewDefault() err := cfg.ManageSync(domainNames) if err != nil { @@ -216,7 +219,7 @@ func Listen(domainNames []string) (net.Listener, error) { // Calling this function signifies your acceptance to // the CA's Subscriber Agreement and/or Terms of Service. func ManageSync(domainNames []string) error { - Default.Agreed = true + DefaultACME.Agreed = true return NewDefault().ManageSync(domainNames) } @@ -229,7 +232,7 @@ func ManageSync(domainNames []string) error { // which is only recommended for automated/non-interactive // environments. func ManageAsync(ctx context.Context, domainNames []string) error { - Default.Agreed = true + DefaultACME.Agreed = true return NewDefault().ManageAsync(ctx, domainNames) } @@ -275,7 +278,7 @@ type OnDemandConfig struct { func (o *OnDemandConfig) whitelistContains(name string) bool { for _, n := range o.hostWhitelist { - if strings.ToLower(n) == strings.ToLower(name) { + if strings.EqualFold(n, name) { return true } } @@ -328,6 +331,93 @@ func hostOnly(hostport string) string { return host } +// PreChecker is an interface that can be optionally implemented by +// Issuers. Pre-checks are performed before each call (or batch of +// identical calls) to Issue(), giving the issuer the option to ensure +// it has all the necessary information/state, or to skip the given +// operation. +type PreChecker interface { + PreCheck(names []string, interactive bool) (skip bool, err error) +} + +// Issuer is a type that can issue certificates. +type Issuer interface { + // Issue obtains a certificate for the given CSR. It + // must honor context cancellation if it is long-running. + // It can also use the context to find out if the current + // call is part of a retry, via AttemptsCtxKey. + Issue(ctx context.Context, request *x509.CertificateRequest) (*IssuedCertificate, error) + + // IssuerKey must return a string that uniquely identifies + // this particular configuration of the Issuer such that + // any certificates obtained by this Issuer will be treated + // as identical if they have the same SANs. + // + // Certificates obtained from Issuers with the same IssuerKey + // will overwrite others with the same SANs. For example, an + // Issuer might be able to obtain certificates from different + // CAs, say A and B. It is likely that the CAs have different + // use cases and purposes (e.g. testing and production), so + // their respective certificates should not overwrite eaach + // other. + IssuerKey() string +} + +// Revoker can revoke certificates. +type Revoker interface { + Revoke(ctx context.Context, cert CertificateResource) error +} + +// KeyGenerator can generate a private key. +type KeyGenerator interface { + // GenerateKey generates a private key. The returned + // PrivateKey must be able to expose its associated + // public key. + GenerateKey() (crypto.PrivateKey, error) +} + +// IssuedCertificate represents a certificate that was just issued. +type IssuedCertificate struct { + // The PEM-encoding of DER-encoded ASN.1 data. + Certificate []byte + + // Any extra information to serialize alongside the + // certificate in storage. + Metadata interface{} +} + +// CertificateResource associates a certificate with its private +// key and other useful information, for use in maintaining the +// certificate. +type CertificateResource struct { + // The list of names on the certificate; + // for convenience only. + SANs []string `json:"sans,omitempty"` + + // The PEM-encoding of DER-encoded ASN.1 data + // for the cert or chain. + CertificatePEM []byte `json:"-"` + + // The PEM-encoding of the certificate's private key. + PrivateKeyPEM []byte `json:"-"` + + // Any extra information associated with the certificate, + // usually provided by the issuer implementation. + IssuerData interface{} `json:"issuer_data,omitempty"` +} + +// NamesKey returns the list of SANs as a single string, +// truncated to some ridiculously long size limit. It +// can act as a key for the set of names on the resource. +func (cr *CertificateResource) NamesKey() string { + sort.Strings(cr.SANs) + result := strings.Join(cr.SANs, ",") + if len(result) > 1024 { + result = result[1018:] + "_trunc" + } + return result +} + // Default contains the package defaults for the // various Config fields. This is used as a template // when creating your own Configs with New(), and it @@ -344,10 +434,9 @@ func hostOnly(hostport string) string { // cache). This is the only Config which can access // the default certificate cache. var Default = Config{ - CA: LetsEncryptProductionCA, RenewDurationBefore: DefaultRenewDurationBefore, - KeyType: certcrypto.EC256, Storage: defaultFileStorage, + KeySource: DefaultKeyGenerator, } const ( @@ -360,12 +449,6 @@ const ( TLSALPNChallengePort = 443 ) -// Some well-known CA endpoints available to use. -const ( - LetsEncryptStagingCA = "https://acme-staging-v02.api.letsencrypt.org/directory" - LetsEncryptProductionCA = "https://acme-v02.api.letsencrypt.org/directory" -) - // Port variables must remain their defaults unless you // forward packets from the defaults to whatever these // are set to; otherwise ACME challenges will fail. diff --git a/client.go b/client.go deleted file mode 100644 index 14069c89..00000000 --- a/client.go +++ /dev/null @@ -1,659 +0,0 @@ -// Copyright 2015 Matthew Holt -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package certmagic - -import ( - "bytes" - "context" - "crypto/tls" - "fmt" - "log" - weakrand "math/rand" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "sync" - "time" - - "github.com/go-acme/lego/v3/acme" - "github.com/go-acme/lego/v3/certificate" - "github.com/go-acme/lego/v3/challenge" - "github.com/go-acme/lego/v3/lego" - "github.com/go-acme/lego/v3/registration" -) - -func init() { - weakrand.Seed(time.Now().UnixNano()) -} - -// acmeClient is a wrapper over acme.Client with -// some custom state attached. It is used to obtain, -// renew, and revoke certificates with ACME. -type acmeClient struct { - config *Config - acmeClient *lego.Client - challenges []challenge.Type -} - -// listenerAddressInUse returns true if a TCP connection -// can be made to addr within a short time interval. -func listenerAddressInUse(addr string) bool { - conn, err := net.DialTimeout("tcp", addr, 250*time.Millisecond) - if err == nil { - conn.Close() - } - return err == nil -} - -// lockKey returns a key for a lock that is specific to the operation -// named op being performed related to domainName and this config's CA. -func (cfg *Config) lockKey(op, domainName string) string { - return fmt.Sprintf("%s_%s_%s", op, domainName, cfg.CA) -} - -// checkStorage tests the storage by writing random bytes -// to a random key, and then loading those bytes and -// comparing the loaded value. If this fails, the provided -// cfg.Storage mechanism should not be used. -func (cfg *Config) checkStorage() error { - key := fmt.Sprintf("rw_test_%d", weakrand.Int()) - contents := make([]byte, 1024*10) // size sufficient for one or two ACME resources - _, err := weakrand.Read(contents) - if err != nil { - return err - } - err = cfg.Storage.Store(key, contents) - if err != nil { - return err - } - defer func() { - deleteErr := cfg.Storage.Delete(key) - if deleteErr != nil { - log.Printf("[ERROR] Deleting test key %s from storage: %v", key, err) - } - // if there was no other error, make sure - // to return any error returned from Delete - if err == nil { - err = deleteErr - } - }() - loaded, err := cfg.Storage.Load(key) - if err != nil { - return err - } - if !bytes.Equal(contents, loaded) { - return fmt.Errorf("load yielded different value than was stored; expected %d bytes, got %d bytes of differing elements", len(contents), len(loaded)) - } - return nil -} - -func (cfg *Config) newManager(interactive bool) (Manager, error) { - // ensure storage is writeable and readable - // TODO: this is not necessary every time; should only - // perform check once every so often for each storage, - // which may require some global state... - err := cfg.checkStorage() - if err != nil { - return nil, fmt.Errorf("failed storage check: %v - storage is probably misconfigured", err) - } - - const maxTries = 3 - var mgr Manager - for i := 0; i < maxTries; i++ { - if cfg.NewManager != nil { - mgr, err = cfg.NewManager(interactive) - } else { - mgr, err = cfg.newACMEClient(interactive) - } - if err == nil { - break - } - if acmeErr, ok := err.(acme.ProblemDetails); ok { - if acmeErr.HTTPStatus == http.StatusTooManyRequests { - log.Printf("[ERROR] Too many requests when making new ACME client: %+v - aborting", acmeErr) - return nil, err - } - } - log.Printf("[ERROR] Making new certificate manager: %v (attempt %d/%d)", err, i+1, maxTries) - time.Sleep(1 * time.Second) - } - return mgr, err -} - -func (cfg *Config) newACMEClient(interactive bool) (*acmeClient, error) { - acmeClientsMu.Lock() - defer acmeClientsMu.Unlock() - - // ensure key type and timeout are set - keyType := cfg.KeyType - if keyType == "" { - keyType = Default.KeyType - } - certObtainTimeout := cfg.CertObtainTimeout - if certObtainTimeout == 0 { - certObtainTimeout = Default.CertObtainTimeout - } - - // ensure CA URL (directory endpoint) is set - caURL := Default.CA - if cfg.CA != "" { - caURL = cfg.CA - } - - // ensure endpoint is secure (assume HTTPS if scheme is missing) - if !strings.Contains(caURL, "://") { - caURL = "https://" + caURL - } - u, err := url.Parse(caURL) - if err != nil { - return nil, err - } - if u.Scheme != "https" && !isLoopback(u.Host) && !isInternal(u.Host) { - return nil, fmt.Errorf("%s: insecure CA URL (HTTPS required)", caURL) - } - - // look up or create the user account - leUser, err := cfg.getUser(cfg.Email) - if err != nil { - return nil, err - } - - // if a lego client with this configuration already exists, reuse it - clientKey := caURL + leUser.Email + string(keyType) - client, ok := acmeClients[clientKey] - if !ok { - // the client facilitates our communication with the CA server - legoCfg := lego.NewConfig(leUser) - legoCfg.CADirURL = caURL - legoCfg.UserAgent = buildUAString() - legoCfg.HTTPClient.Timeout = HTTPTimeout - legoCfg.Certificate = lego.CertificateConfig{ - KeyType: keyType, - Timeout: certObtainTimeout, - } - if cfg.TrustedRoots != nil { - if ht, ok := legoCfg.HTTPClient.Transport.(*http.Transport); ok { - if ht.TLSClientConfig == nil { - ht.TLSClientConfig = new(tls.Config) - ht.ForceAttemptHTTP2 = true - } - ht.TLSClientConfig.RootCAs = cfg.TrustedRoots - } - } - client, err = lego.NewClient(legoCfg) - if err != nil { - return nil, err - } - acmeClients[clientKey] = client - } - - // if not registered, the user must register an account - // with the CA and agree to terms - if leUser.Registration == nil { - if interactive { // can't prompt a user who isn't there - termsURL := client.GetToSURL() - if !cfg.Agreed && termsURL != "" { - cfg.Agreed = cfg.askUserAgreement(client.GetToSURL()) - } - if !cfg.Agreed && termsURL != "" { - return nil, fmt.Errorf("user must agree to CA terms") - } - } - - reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: cfg.Agreed}) - if err != nil { - return nil, err - } - leUser.Registration = reg - - // persist the user to storage - err = cfg.saveUser(leUser) - if err != nil { - return nil, fmt.Errorf("could not save user: %v", err) - } - } - - c := &acmeClient{ - config: cfg, - acmeClient: client, - } - - return c, nil -} - -// Obtain obtains a single certificate for name. It stores the certificate -// on the disk if successful. This function is safe for concurrent use. -// -// Our storage mechanism only supports one name per certificate, so this -// function (along with Renew and Revoke) only accepts one domain as input. -// It could be easily modified to support SAN certificates if our storage -// mechanism is upgraded later, but that will increase logical complexity -// in other areas and is not recommended at scale (even Cloudflare now -// utilizes fewer-or-single-SAN certificates at their scale: see -// https://twitter.com/epatryk/status/1135615936176775174). -// -// Callers who have access to a Config value should use the ObtainCert -// method on that instead of this lower-level method. -// -// This method is throttled according to RateLimitOrders. -func (c *acmeClient) Obtain(ctx context.Context, name string) error { - if err := c.throttle(ctx, "Obtain", name); err != nil { - return err - } - - // ensure idempotency of the obtain operation for this name - lockKey := c.config.lockKey("cert_acme", name) - err := obtainLock(c.config.Storage, lockKey) - if err != nil { - return err - } - defer func() { - if err := releaseLock(c.config.Storage, lockKey); err != nil { - log.Printf("[ERROR][%s] Obtain: Unable to unlock '%s': %v", name, lockKey, err) - } - }() - - // check if obtain is still needed -- might have been obtained during lock - if c.config.storageHasCertResources(name) { - log.Printf("[INFO][%s] Obtain: Certificate already exists in storage", name) - return nil - } - - challenges := c.initialChallenges() - if len(challenges) == 0 { - log.Printf("[ERROR][%s] No challenge types enabled; obtain is doomed", name) - } - var chosenChallenge challenge.Type - - // try while a challenge type is still available; - // and for each challenge, retry a few times -challengeLoop: - for len(challenges) > 0 { - chosenChallenge, challenges = c.nextChallenge(challenges) - const maxAttempts = 2 - for attempts := 0; attempts < maxAttempts; attempts++ { - err = c.tryObtain(name) - if err == nil { - break challengeLoop - } - log.Printf("[ERROR][%s] %s (attempt %d/%d; challenge=%s)", - name, strings.TrimSpace(err.Error()), attempts+1, maxAttempts, chosenChallenge) - time.Sleep(1 * time.Second) - } - } - if err != nil { - return err - } - - if c.config.OnEvent != nil { - c.config.OnEvent("acme_cert_obtained", name) - } - - return nil -} - -// tryObtain uses the underlying ACME client to obtain a -// certificate for name and puts the result in storage if -// it succeeds. There are no retries here and c must be -// fully configured already. -func (c *acmeClient) tryObtain(name string) error { - request := certificate.ObtainRequest{ - Domains: []string{name}, - Bundle: true, - MustStaple: c.config.MustStaple, - } - certificate, err := c.acmeClient.Certificate.Obtain(request) - if err != nil { - return fmt.Errorf("failed to obtain certificate: %v", err) - } - - // double-check that we actually got a certificate, in case there's a bug upstream - // (see issue mholt/caddy#2121) - if certificate.Domain == "" || certificate.Certificate == nil { - return fmt.Errorf("returned certificate was empty; probably an unchecked error obtaining it") - } - - // Success - immediately save the certificate resource - err = c.config.saveCertResource(certificate) - if err != nil { - return fmt.Errorf("saving assets: %v", err) - } - - return nil -} - -// Renew renews the managed certificate for name. It puts the renewed -// certificate into storage (not the cache). This function is safe for -// concurrent use. -// -// Callers who have access to a Config value should use the RenewCert -// method on that instead of this lower-level method. -// -// This method is throttled according to RateLimitOrders. -func (c *acmeClient) Renew(ctx context.Context, name string) error { - if err := c.throttle(ctx, "Renew", name); err != nil { - return err - } - - // ensure idempotency of the renew operation for this name - lockKey := c.config.lockKey("cert_acme", name) - err := obtainLock(c.config.Storage, lockKey) - if err != nil { - return err - } - defer func() { - if err := releaseLock(c.config.Storage, lockKey); err != nil { - log.Printf("[ERROR][%s] Renew: Unable to unlock '%s': %v", name, lockKey, err) - } - }() - - // Prepare for renewal (load PEM cert, key, and meta) - certRes, err := c.config.loadCertResource(name) - if err != nil { - return err - } - - // Check if renew is still needed - might have been renewed while waiting for lock - if !c.config.managedCertNeedsRenewal(certRes) { - log.Printf("[INFO][%s] Renew: Certificate appears to have been renewed already", name) - return nil - } - - challenges := c.initialChallenges() - if len(challenges) == 0 { - log.Printf("[ERROR][%s] No challenge types enabled; renew is doomed", name) - } - var chosenChallenge challenge.Type - - // try while a challenge type is still available; - // and for each challenge, retry a few times -challengeLoop: - for len(challenges) > 0 { - chosenChallenge, challenges = c.nextChallenge(challenges) - const maxAttempts = 2 - for attempts := 0; attempts < maxAttempts; attempts++ { - // TODO: consider moving throttle to here instead, the only potentially negative consequence is that the lock in storage may be persisted and get "stale" when it's actually not stale... - err = c.tryRenew(certRes) - if err == nil { - break challengeLoop - } - log.Printf("[ERROR][%s] %s (attempt %d/%d; challenge=%s)", - name, strings.TrimSpace(err.Error()), attempts+1, maxAttempts, chosenChallenge) - time.Sleep(1 * time.Second) - } - } - if err != nil { - return err - } - - if c.config.OnEvent != nil { - c.config.OnEvent("acme_cert_renewed", name) - } - - return nil -} - -// tryRenew uses the underlying ACME client to renew the -// certificate represented by certRes and puts the result -// in storage if it succeeds. There are no retries here -// and c must be fully configured already. -func (c *acmeClient) tryRenew(certRes certificate.Resource) error { - newCertMeta, err := c.acmeClient.Certificate.Renew(certRes, true, c.config.MustStaple) - if err != nil { - return fmt.Errorf("failed to renew certificate: %v", err) - } - - // double-check that we actually got a certificate, in case there's a bug upstream - // (see issue mholt/caddy#2121) - if newCertMeta == nil || newCertMeta.Domain == "" || newCertMeta.Certificate == nil { - return fmt.Errorf("returned certificate was empty; probably an unchecked error renewing it") - } - - // Success - immediately save the renewed certificate resource - err = c.config.saveCertResource(newCertMeta) - if err != nil { - return fmt.Errorf("saving assets: %v", err) - } - - return nil -} - -// Revoke revokes the certificate for name and deletes it from storage. -func (c *acmeClient) Revoke(_ context.Context, name string) error { - if !c.config.Storage.Exists(StorageKeys.SitePrivateKey(c.config.CA, name)) { - return fmt.Errorf("private key not found for %s", name) - } - - certRes, err := c.config.loadCertResource(name) - if err != nil { - return err - } - - err = c.acmeClient.Certificate.Revoke(certRes.Certificate) - if err != nil { - return err - } - - if c.config.OnEvent != nil { - c.config.OnEvent("acme_cert_revoked", name) - } - - err = c.config.Storage.Delete(StorageKeys.SiteCert(c.config.CA, name)) - if err != nil { - return fmt.Errorf("certificate revoked, but unable to delete certificate file: %v", err) - } - err = c.config.Storage.Delete(StorageKeys.SitePrivateKey(c.config.CA, name)) - if err != nil { - return fmt.Errorf("certificate revoked, but unable to delete private key: %v", err) - } - err = c.config.Storage.Delete(StorageKeys.SiteMeta(c.config.CA, name)) - if err != nil { - return fmt.Errorf("certificate revoked, but unable to delete certificate metadata: %v", err) - } - - return nil -} - -// initialChallenges returns the initial set of challenges -// to try using c.config as a basis. -func (c *acmeClient) initialChallenges() []challenge.Type { - // if configured, use DNS challenge exclusively - if c.config.DNSProvider != nil { - return []challenge.Type{challenge.DNS01} - } - - // otherwise, use HTTP and TLS-ALPN challenges if enabled - var chal []challenge.Type - if !c.config.DisableHTTPChallenge { - chal = append(chal, challenge.HTTP01) - } - if !c.config.DisableTLSALPNChallenge { - chal = append(chal, challenge.TLSALPN01) - } - return chal -} - -// nextChallenge chooses a challenge randomly from the given list of -// available challenges and configures c.acmeClient to use that challenge -// according to c.config. It pops the chosen challenge from the list and -// returns that challenge along with the new list without that challenge. -// If len(available) == 0, this is a no-op. -// -// Don't even get me started on how dumb it is we need to do this here -// instead of the upstream lego library doing it for us. Lego used to -// randomize the challenge order, thus allowing another one to be used -// if the first one failed. https://github.com/go-acme/lego/issues/842 -// (It also has an awkward API for adjusting the available challenges.) -// At time of writing, lego doesn't try anything other than the TLS-ALPN -// challenge, even if the HTTP challenge is also enabled. So we take -// matters into our own hands and enable only one challenge at a time -// in the underlying client, randomly selected by us. -func (c *acmeClient) nextChallenge(available []challenge.Type) (challenge.Type, []challenge.Type) { - if len(available) == 0 { - return "", available - } - - // make sure we choose a challenge randomly, which lego used to do but - // the critical feature was surreptitiously removed in ~2018 in a commit - // too large to review, oh well - choose one, then remove it from the - // list of available challenges so it doesn't get retried - randIdx := weakrand.Intn(len(available)) - randomChallenge := available[randIdx] - available = append(available[:randIdx], available[randIdx+1:]...) - - // clean the slate, since we reuse clients - c.acmeClient.Challenge.Remove(challenge.HTTP01) - c.acmeClient.Challenge.Remove(challenge.TLSALPN01) - c.acmeClient.Challenge.Remove(challenge.DNS01) - - switch randomChallenge { - case challenge.HTTP01: - useHTTPPort := HTTPChallengePort - if HTTPPort > 0 && HTTPPort != HTTPChallengePort { - useHTTPPort = HTTPPort - } - if c.config.AltHTTPPort > 0 { - useHTTPPort = c.config.AltHTTPPort - } - - c.acmeClient.Challenge.SetHTTP01Provider(distributedSolver{ - config: c.config, - providerServer: &httpSolver{ - config: c.config, - address: net.JoinHostPort(c.config.ListenHost, strconv.Itoa(useHTTPPort)), - }, - }) - - case challenge.TLSALPN01: - useTLSALPNPort := TLSALPNChallengePort - if HTTPSPort > 0 && HTTPSPort != TLSALPNChallengePort { - useTLSALPNPort = HTTPSPort - } - if c.config.AltTLSALPNPort > 0 { - useTLSALPNPort = c.config.AltTLSALPNPort - } - - c.acmeClient.Challenge.SetTLSALPN01Provider(distributedSolver{ - config: c.config, - providerServer: &tlsALPNSolver{ - config: c.config, - address: net.JoinHostPort(c.config.ListenHost, strconv.Itoa(useTLSALPNPort)), - }, - }) - - case challenge.DNS01: - if c.config.DNSChallengeOption != nil { - c.acmeClient.Challenge.SetDNS01Provider(c.config.DNSProvider, c.config.DNSChallengeOption) - } else { - c.acmeClient.Challenge.SetDNS01Provider(c.config.DNSProvider) - } - } - - return randomChallenge, available -} - -func (c *acmeClient) throttle(ctx context.Context, op, name string) error { - rateLimiterKey := c.config.CA + "," + c.config.Email - rateLimitersMu.Lock() - rl, ok := rateLimiters[rateLimiterKey] - if !ok { - rl = NewRateLimiter(RateLimitOrders, RateLimitOrdersWindow) - rateLimiters[rateLimiterKey] = rl - // TODO: stop rate limiter when it is garbage-collected... - } - rateLimitersMu.Unlock() - log.Printf("[INFO][%s] %s: Waiting on rate limiter...", name, op) - err := rl.Wait(ctx) - if err != nil { - return err - } - log.Printf("[INFO][%s] %s: Done waiting", name, op) - return nil -} - -func buildUAString() string { - ua := "CertMagic" - if UserAgent != "" { - ua += " " + UserAgent - } - return ua -} - -// These internal rate limits are designed to prevent accidentally -// firehosing a CA's ACME endpoints. They are not intended to -// replace or reimplement the CA's actual rate limits. -// -// Let's Encrypt's rate limits can be found here: -// https://letsencrypt.org/docs/rate-limits/ -// -// Currently (as of December 2019), Let's Encrypt's most relevant -// rate limit for large deployments is 300 new orders per account -// per 3 hours (on average, or best case, that's about 1 every 36 -// seconds, or 2 every 72 seconds, etc.); but it's not reasonable -// to try to assume that our internal state is the same as the CA's -// (due to process restarts, config changes, failed validations, -// etc.) and ultimately, only the CA's actual rate limiter is the -// authority. Thus, our own rate limiters do not attempt to enforce -// external rate limits. Doing so causes problems when the domains -// are not in our control (i.e. serving customer sites) and/or lots -// of domains fail validation: they clog our internal rate limiter -// and nearly starve out (or at least slow down) the other domains -// that need certificates. Failed transactions are already retried -// with exponential backoff, so adding in rate limiting can slow -// things down even more. -// -// Instead, the point of our internal rate limiter is to avoid -// hammering the CA's endpoint when there are thousands or even -// millions of certificates under management. Our goal is to -// allow small bursts in a relatively short timeframe so as to -// not block any one domain for too long, without unleashing -// thousands of requests to the CA at once. -var ( - rateLimiters = make(map[string]*RingBufferRateLimiter) - rateLimitersMu sync.RWMutex - - // RateLimitOrders is how many new ACME orders can be made per - // account in RateLimitNewOrdersWindow. - RateLimitOrders = 10 - - // RateLimitOrdersWindow is the size of the sliding - // window that throttles new ACME orders. - RateLimitOrdersWindow = 1 * time.Minute -) - -// We keep a global cache of ACME clients so that they -// can be reused. Since the number of CAs, accounts, -// and key types should be fairly limited under best -// practices, this map will hardly ever have more than -// a few entries at the most. The associated lock -// protects access to the map but also ensures that only -// one ACME client is created at a time. -// TODO: consider using storage for a distributed lock -// TODO: consider evicting clients after some time -var ( - acmeClients = make(map[string]*lego.Client) - acmeClientsMu sync.Mutex -) - -// Some default values passed down to the underlying lego client. -var ( - UserAgent string - HTTPTimeout = 30 * time.Second -) - -// Interface guard -var _ Manager = (*acmeClient)(nil) diff --git a/config.go b/config.go index e6eab2c2..9a0fb59f 100644 --- a/config.go +++ b/config.go @@ -15,19 +15,22 @@ package certmagic import ( + "bytes" "context" + "crypto" + "crypto/rand" "crypto/tls" "crypto/x509" - "errors" + "crypto/x509/pkix" + "encoding/asn1" "fmt" "log" + weakrand "math/rand" + "net" + "net/url" "strings" "time" - "github.com/go-acme/lego/v3/certcrypto" - "github.com/go-acme/lego/v3/certificate" - "github.com/go-acme/lego/v3/challenge" - "github.com/go-acme/lego/v3/challenge/dns01" "github.com/go-acme/lego/v3/challenge/tlsalpn01" ) @@ -35,24 +38,6 @@ import ( // An empty Config is not valid: use New() to obtain // a valid Config. type Config struct { - // The endpoint of the directory for the ACME - // CA we are to use - CA string - - // The email address to use when creating or - // selecting an existing ACME server account - Email string - - // Set to true if agreed to the CA's - // subscriber agreement - Agreed bool - - // Disable all HTTP challenges - DisableHTTPChallenge bool - - // Disable all TLS-ALPN challenges - DisableTLSALPNChallenge bool - // How long before expiration to renew certificates RenewDurationBefore time.Duration @@ -62,44 +47,6 @@ type Config struct { // synchronous, so make them return quickly! OnEvent func(event string, data interface{}) - // The host (ONLY the host, not port) to listen - // on if necessary to start a listener to solve - // an ACME challenge - ListenHost string - - // The alternate port to use for the ACME HTTP - // challenge; if non-empty, this port will be - // used instead of HTTPChallengePort to spin up - // a listener for the HTTP challenge - AltHTTPPort int - - // The alternate port to use for the ACME - // TLS-ALPN challenge; the system must forward - // TLSALPNChallengePort to this port for - // challenge to succeed - AltTLSALPNPort int - - // The DNS provider to use when solving the - // ACME DNS challenge - DNSProvider challenge.Provider - - // The ChallengeOption struct to provide - // custom precheck or name resolution options - // for DNS challenge validation and execution - DNSChallengeOption dns01.ChallengeOption - - // The type of key to use when generating - // certificates - KeyType certcrypto.KeyType - - // The maximum amount of time to allow for - // obtaining a certificate. If empty, the - // default from the underlying lego lib is - // used. If set, it must not be too low so - // as to cancel orders too early, running - // the risk of rate limiting. - CertObtainTimeout time.Duration - // DefaultServerName specifies a server name // to use when choosing a certificate if the // ClientHello's ServerName field is empty @@ -115,26 +62,32 @@ type Config struct { // CSR generated by lego/acme MustStaple bool - // The storage to access when storing or - // loading TLS assets - Storage Storage + // The type that issues certificates; the + // default Issuer is ACMEManager + Issuer Issuer - // NewManager returns a new Manager. If nil, - // an ACME client will be created and used. - NewManager func(interactive bool) (Manager, error) + // The type that revokes certificates; must + // be configured in conjunction with the Issuer + // field such that both the Issuer and Revoker + // are related (because issuance information is + // required for revocation) + Revoker Revoker + + // The source of new private keys for certificates; + // the default KeySource is StandardKeyGenerator + KeySource KeyGenerator // CertSelection chooses one of the certificates - // with which the ClientHello will be completed. - // If not set, the first matching certificate - // will be used. + // with which the ClientHello will be completed; + // if not set, the first matching certificate + // will be selected CertSelection CertificateSelector - // TrustedRoots specifies a pool of root CA - // certificates to trust when communicating - // over a network to a peer. - TrustedRoots *x509.CertPool + // The storage to access when storing or + // loading TLS assets + Storage Storage - // Pointer to the in-memory certificate cache + // required pointer to the in-memory cert cache certCache *Cache } @@ -211,51 +164,17 @@ func newWithCache(certCache *Cache, cfg Config) *Config { panic("cannot make a valid config without a pointer to a certificate cache") } - // fill in default values - if cfg.CA == "" { - cfg.CA = Default.CA - } - if cfg.Email == "" { - cfg.Email = Default.Email - } if cfg.OnDemand == nil { cfg.OnDemand = Default.OnDemand } - if !cfg.Agreed { - cfg.Agreed = Default.Agreed - } - if !cfg.DisableHTTPChallenge { - cfg.DisableHTTPChallenge = Default.DisableHTTPChallenge - } - if !cfg.DisableTLSALPNChallenge { - cfg.DisableTLSALPNChallenge = Default.DisableTLSALPNChallenge - } if cfg.RenewDurationBefore == 0 { cfg.RenewDurationBefore = Default.RenewDurationBefore } if cfg.OnEvent == nil { cfg.OnEvent = Default.OnEvent } - if cfg.ListenHost == "" { - cfg.ListenHost = Default.ListenHost - } - if cfg.AltHTTPPort == 0 { - cfg.AltHTTPPort = Default.AltHTTPPort - } - if cfg.AltTLSALPNPort == 0 { - cfg.AltTLSALPNPort = Default.AltTLSALPNPort - } - if cfg.DNSProvider == nil { - cfg.DNSProvider = Default.DNSProvider - } - if cfg.DNSChallengeOption == nil { - cfg.DNSChallengeOption = Default.DNSChallengeOption - } - if cfg.KeyType == "" { - cfg.KeyType = Default.KeyType - } - if cfg.CertObtainTimeout == 0 { - cfg.CertObtainTimeout = Default.CertObtainTimeout + if cfg.KeySource == nil { + cfg.KeySource = Default.KeySource } if cfg.DefaultServerName == "" { cfg.DefaultServerName = Default.DefaultServerName @@ -269,8 +188,23 @@ func newWithCache(certCache *Cache, cfg Config) *Config { if cfg.Storage == nil { cfg.Storage = Default.Storage } - if cfg.NewManager == nil { - cfg.NewManager = Default.NewManager + if cfg.Issuer == nil { + cfg.Issuer = Default.Issuer + if cfg.Issuer == nil { + // okay really, we need an issuer, + // that's kind of the point; most + // people would probably want ACME + cfg.Issuer = NewACMEManager(&cfg, DefaultACME) + } + // issuer and revoker go together; if user + // specifies their own issuer, we don't want + // to override their revoker + if cfg.Revoker == nil { + cfg.Revoker = Default.Revoker + if cfg.Revoker == nil { + cfg.Revoker = NewACMEManager(&cfg, DefaultACME) + } + } } // absolutely don't allow a nil storage, @@ -336,13 +270,6 @@ func (cfg *Config) manageAll(ctx context.Context, domainNames []string, async bo ctx = context.Background() } - // first, check all domains for validity - for _, domainName := range domainNames { - if !HostQualifies(domainName) { - return fmt.Errorf("name does not qualify for automatic certificate management: %s", domainName) - } - } - for _, domainName := range domainNames { // if on-demand is configured, defer obtain and renew operations if cfg.OnDemand != nil { @@ -351,57 +278,27 @@ func (cfg *Config) manageAll(ctx context.Context, domainNames []string, async bo } continue } - if async { - go func(domainName string) { - var wait time.Duration - // the first 17 iterations ramp up the wait interval to - // ~24h, and the remaining iterations retry at that - // maximum backoff until giving up after ~30 days total. - const maxIter = 46 - for i := 1; i <= maxIter; i++ { - timer := time.NewTimer(wait) - select { - case <-ctx.Done(): - timer.Stop() - log.Printf("[ERROR][%s] Context cancelled", domainName) - return - case <-timer.C: - err := cfg.manageOne(ctx, domainName) - if err == nil { - return - } - if !errors.Is(err, context.Canceled) { - log.Printf("[ERROR] %s - backing off and retrying (attempt %d/%d)...", strings.TrimSpace(err.Error()), i, maxIter) - } - } - // retry with exponential backoff - if wait == 0 { - // this starting interval (~2.6 seconds) doubles nicely to ~24h - wait = 2636719 * time.Microsecond - } else if wait < 24*time.Hour { - wait *= 2 - } - } - }(domainName) - } else { - err := cfg.manageOne(ctx, domainName) - if err != nil { - return err - } + + // otherwise, begin management immediately + err := cfg.manageOne(ctx, domainName, async) + if err != nil { + return err } } return nil } -func (cfg *Config) manageOne(ctx context.Context, domainName string) error { - // try loading an existing certificate; if it doesn't - // exist yet, obtain one and try loading it again +func (cfg *Config) manageOne(ctx context.Context, domainName string, async bool) error { + // first try loading existing certificate from storage cert, err := cfg.CacheManagedCertificate(domainName) if err != nil { - if _, ok := err.(ErrNotExist); ok { - // if it doesn't exist, get it, then try loading it again - err := cfg.ObtainCert(ctx, domainName, false) + if _, ok := err.(ErrNotExist); !ok { + return fmt.Errorf("%s: caching certificate: %v", domainName, err) + } + // if we don't have one in storage, obtain one + obtain := func() error { + err := cfg.ObtainCert(ctx, domainName, !async) if err != nil { return fmt.Errorf("%s: obtaining certificate: %w", domainName, err) } @@ -411,15 +308,32 @@ func (cfg *Config) manageOne(ctx context.Context, domainName string) error { } return nil } - return fmt.Errorf("%s: caching certificate: %v", domainName, err) + if async { + jm.Submit("obtain_"+domainName, obtain) + return nil + } + return obtain() } - // for existing certificates, make sure it is renewed - if cert.NeedsRenewal(cfg) { - err := cfg.RenewCert(ctx, domainName, false) + // for an existing certificate, make sure it is renewed + renew := func() error { + err := cfg.RenewCert(ctx, domainName, !async) if err != nil { return fmt.Errorf("%s: renewing certificate: %w", domainName, err) } + // successful renewal, so update in-memory cache + err = cfg.reloadManagedCertificate(cert) + if err != nil { + return fmt.Errorf("%s: reloading renewed certificate into memory: %v", domainName, err) + } + return nil + } + if cert.NeedsRenewal(cfg) { + if async { + jm.Submit("renew_"+domainName, renew) + return nil + } + return renew() } return nil @@ -438,46 +352,245 @@ func (cfg *Config) ObtainCert(ctx context.Context, name string, interactive bool if cfg.storageHasCertResources(name) { return nil } - skip, err := cfg.preObtainOrRenewChecks(name, interactive) + issuer, err := cfg.getPrecheckedIssuer([]string{name}, interactive) if err != nil { return err } - if skip { + if issuer == nil { + return nil + } + return cfg.obtainWithIssuer(ctx, issuer, name, interactive) +} + +func (cfg *Config) obtainWithIssuer(ctx context.Context, issuer Issuer, name string, interactive bool) error { + log.Printf("[INFO][%s] Obtain certificate; acquiring lock...", name) + + // ensure idempotency of the obtain operation for this name + lockKey := cfg.lockKey("cert_acme", name) + err := obtainLock(cfg.Storage, lockKey) + if err != nil { + return err + } + defer func() { + log.Printf("[INFO][%s] Obtain: Releasing lock", name) + if err := releaseLock(cfg.Storage, lockKey); err != nil { + log.Printf("[ERROR][%s] Obtain: Unable to unlock '%s': %v", name, lockKey, err) + } + }() + log.Printf("[INFO][%s] Obtain: Lock acquired; proceeding...", name) + + f := func(ctx context.Context) error { + // check if obtain is still needed -- might have been obtained during lock + if cfg.storageHasCertResources(name) { + log.Printf("[INFO][%s] Obtain: Certificate already exists in storage", name) + return nil + } + + privateKey, err := cfg.KeySource.GenerateKey() + if err != nil { + return err + } + privKeyPEM, err := encodePrivateKey(privateKey) + if err != nil { + return err + } + + csr, err := cfg.generateCSR(privateKey, []string{name}) + if err != nil { + return err + } + + issuedCert, err := issuer.Issue(ctx, csr) + if err != nil { + return fmt.Errorf("[%s] Obtain: %w", name, err) + } + + // success - immediately save the certificate resource + certRes := CertificateResource{ + SANs: namesFromCSR(csr), + CertificatePEM: issuedCert.Certificate, + PrivateKeyPEM: privKeyPEM, + IssuerData: issuedCert.Metadata, + } + err = cfg.saveCertResource(certRes) + if err != nil { + return fmt.Errorf("[%s] Obtain: saving assets: %v", name, err) + } return nil } - manager, err := cfg.newManager(interactive) + if interactive { + err = f(ctx) + } else { + err = doWithRetry(ctx, f) + } if err != nil { return err } - log.Printf("[INFO][%s] Obtain certificate", name) - return manager.Obtain(ctx, name) + + cfg.emit("cert_obtained", name) + + log.Printf("[INFO][%s] Certificate obtained successfully", name) + + return nil } // RenewCert renews the certificate for name using cfg. It stows the -// renewed certificate and its assets in storage if successful. +// renewed certificate and its assets in storage if successful. It +// DOES NOT update the in-memory cache with the new certificate. func (cfg *Config) RenewCert(ctx context.Context, name string, interactive bool) error { - skip, err := cfg.preObtainOrRenewChecks(name, interactive) + issuer, err := cfg.getPrecheckedIssuer([]string{name}, interactive) + if err != nil { + return err + } + if issuer == nil { + return nil + } + return cfg.renewWithIssuer(ctx, issuer, name, interactive) +} + +func (cfg *Config) renewWithIssuer(ctx context.Context, issuer Issuer, name string, interactive bool) error { + log.Printf("[INFO][%s] Renew certificate; acquiring lock...", name) + + // ensure idempotency of the renew operation for this name + lockKey := cfg.lockKey("cert_acme", name) + err := obtainLock(cfg.Storage, lockKey) if err != nil { return err } - if skip { + defer func() { + log.Printf("[INFO][%s] Renew: Releasing lock", name) + if err := releaseLock(cfg.Storage, lockKey); err != nil { + log.Printf("[ERROR][%s] Renew: Unable to unlock '%s': %v", name, lockKey, err) + } + }() + log.Printf("[INFO][%s] Renew: Lock acquired; proceeding...", name) + + f := func(ctx context.Context) error { + // prepare for renewal (load PEM cert, key, and meta) + certRes, err := cfg.loadCertResource(name) + if err != nil { + return err + } + + // check if renew is still needed - might have been renewed while waiting for lock + timeLeft, needsRenew := cfg.managedCertNeedsRenewal(certRes) + if !needsRenew { + log.Printf("[INFO][%s] Renew: Certificate appears to have been renewed already (expires in %s)", name, timeLeft) + return nil + } + log.Printf("[INFO][%s] Renew: %s remaining", name, timeLeft) + + privateKey, err := decodePrivateKey(certRes.PrivateKeyPEM) + if err != nil { + return err + } + csr, err := cfg.generateCSR(privateKey, []string{name}) + if err != nil { + return err + } + + issuedCert, err := issuer.Issue(ctx, csr) + if err != nil { + return fmt.Errorf("[%s] Renew: %w", name, err) + } + + // success - immediately save the renewed certificate resource + newCertRes := CertificateResource{ + SANs: namesFromCSR(csr), + CertificatePEM: issuedCert.Certificate, + PrivateKeyPEM: certRes.PrivateKeyPEM, + IssuerData: issuedCert.Metadata, + } + err = cfg.saveCertResource(newCertRes) + if err != nil { + return fmt.Errorf("[%s] Renew: saving assets: %v", name, err) + } return nil } - manager, err := cfg.newManager(interactive) + if interactive { + err = f(ctx) + } else { + err = doWithRetry(ctx, f) + } if err != nil { return err } - log.Printf("[INFO][%s] Renew certificate", name) - return manager.Renew(ctx, name) + + cfg.emit("cert_renewed", name) + + log.Printf("[INFO][%s] Certificate renewed successfully", name) + + return nil } -// RevokeCert revokes the certificate for domain via ACME protocol. +func (cfg *Config) generateCSR(privateKey crypto.PrivateKey, sans []string) (*x509.CertificateRequest, error) { + csrTemplate := new(x509.CertificateRequest) + + for _, name := range sans { + if ip := net.ParseIP(name); ip != nil { + csrTemplate.IPAddresses = append(csrTemplate.IPAddresses, ip) + } else if strings.Contains(name, "@") { + csrTemplate.EmailAddresses = append(csrTemplate.EmailAddresses, name) + } else if u, err := url.Parse(name); err == nil && strings.Contains(name, "/") { + csrTemplate.URIs = append(csrTemplate.URIs, u) + } else { + csrTemplate.DNSNames = append(csrTemplate.DNSNames, name) + } + } + + if cfg.MustStaple { + csrTemplate.ExtraExtensions = append(csrTemplate.ExtraExtensions, mustStapleExtension) + } + + csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privateKey) + if err != nil { + return nil, err + } + + return x509.ParseCertificateRequest(csrDER) +} + +// RevokeCert revokes the certificate for domain via ACME protocol. It requires +// that cfg.Issuer is properly configured with the same issuer that issued the +// certificate being revoked. func (cfg *Config) RevokeCert(ctx context.Context, domain string, interactive bool) error { - manager, err := cfg.newManager(interactive) + rev := cfg.Revoker + if rev == nil { + rev = Default.Revoker + } + + certRes, err := cfg.loadCertResource(domain) if err != nil { return err } - return manager.Revoke(ctx, domain) + + issuerKey := cfg.Issuer.IssuerKey() + + if !cfg.Storage.Exists(StorageKeys.SitePrivateKey(issuerKey, domain)) { + return fmt.Errorf("private key not found for %s", certRes.SANs) + } + + err = rev.Revoke(ctx, certRes) + if err != nil { + return err + } + + cfg.emit("cert_revoked", domain) + + err = cfg.Storage.Delete(StorageKeys.SiteCert(issuerKey, domain)) + if err != nil { + return fmt.Errorf("certificate revoked, but unable to delete certificate file: %v", err) + } + err = cfg.Storage.Delete(StorageKeys.SitePrivateKey(issuerKey, domain)) + if err != nil { + return fmt.Errorf("certificate revoked, but unable to delete private key: %v", err) + } + err = cfg.Storage.Delete(StorageKeys.SiteMeta(issuerKey, domain)) + if err != nil { + return fmt.Errorf("certificate revoked, but unable to delete certificate metadata: %v", err) + } + + return nil } // TLSConfig is an opinionated method that returns a @@ -511,23 +624,66 @@ func (cfg *Config) TLSConfig() *tls.Config { } } -// preObtainOrRenewChecks perform a few simple checks before -// obtaining or renewing a certificate with ACME, and returns -// whether this name should be skipped (like if it's not -// managed TLS) as well as any error. It ensures that the -// config is Managed, that the name qualifies for a certificate, -// and that an email address is available. -func (cfg *Config) preObtainOrRenewChecks(name string, allowPrompts bool) (bool, error) { - if !HostQualifies(name) { - return true, nil +// getPrecheckedIssuer returns an Issuer with pre-checks +// completed, if it is also a PreChecker. It also checks +// that storage is functioning. If a nil Issuer is returned +// with a nil error, that means to skip this operation +// (not an error, just a no-op). +func (cfg *Config) getPrecheckedIssuer(names []string, interactive bool) (Issuer, error) { + // ensure storage is writeable and readable + // TODO: this is not necessary every time; should only + // perform check once every so often for each storage, + // which may require some global state... + err := cfg.checkStorage() + if err != nil { + return nil, fmt.Errorf("failed storage check: %v - storage is probably misconfigured", err) + } + if prechecker, ok := cfg.Issuer.(PreChecker); ok { + skip, err := prechecker.PreCheck(names, interactive) + if err != nil { + return nil, err + } + if skip { + return nil, nil + } } + return cfg.Issuer, nil +} - err := cfg.getEmail(allowPrompts) +// checkStorage tests the storage by writing random bytes +// to a random key, and then loading those bytes and +// comparing the loaded value. If this fails, the provided +// cfg.Storage mechanism should not be used. +func (cfg *Config) checkStorage() error { + key := fmt.Sprintf("rw_test_%d", weakrand.Int()) + contents := make([]byte, 1024*10) // size sufficient for one or two ACME resources + _, err := weakrand.Read(contents) if err != nil { - return false, err + return err } - - return false, nil + err = cfg.Storage.Store(key, contents) + if err != nil { + return err + } + defer func() { + deleteErr := cfg.Storage.Delete(key) + if deleteErr != nil { + log.Printf("[ERROR] Deleting test key %s from storage: %v", key, err) + } + // if there was no other error, make sure + // to return any error returned from Delete + if err == nil { + err = deleteErr + } + }() + loaded, err := cfg.Storage.Load(key) + if err != nil { + return err + } + if !bytes.Equal(contents, loaded) { + return fmt.Errorf("load yielded different value than was stored; expected %d bytes, got %d bytes of differing elements", len(contents), len(loaded)) + } + return nil } // storageHasCertResources returns true if the storage @@ -535,34 +691,50 @@ func (cfg *Config) preObtainOrRenewChecks(name string, allowPrompts bool) (bool, // resources related to the certificate for domain: the // certificate, the private key, and the metadata. func (cfg *Config) storageHasCertResources(domain string) bool { - certKey := StorageKeys.SiteCert(cfg.CA, domain) - keyKey := StorageKeys.SitePrivateKey(cfg.CA, domain) - metaKey := StorageKeys.SiteMeta(cfg.CA, domain) + issuerKey := cfg.Issuer.IssuerKey() + certKey := StorageKeys.SiteCert(issuerKey, domain) + keyKey := StorageKeys.SitePrivateKey(issuerKey, domain) + metaKey := StorageKeys.SiteMeta(issuerKey, domain) return cfg.Storage.Exists(certKey) && cfg.Storage.Exists(keyKey) && cfg.Storage.Exists(metaKey) } +// lockKey returns a key for a lock that is specific to the operation +// named op being performed related to domainName and this config's CA. +func (cfg *Config) lockKey(op, domainName string) string { + return fmt.Sprintf("%s_%s_%s", op, domainName, cfg.Issuer.IssuerKey()) +} + // managedCertNeedsRenewal returns true if certRes is // expiring soon or already expired, or if the process // of checking the expiration returned an error. -func (cfg *Config) managedCertNeedsRenewal(certRes certificate.Resource) bool { - cert, err := makeCertificate(certRes.Certificate, certRes.PrivateKey) +func (cfg *Config) managedCertNeedsRenewal(certRes CertificateResource) (time.Duration, bool) { + cert, err := makeCertificate(certRes.CertificatePEM, certRes.PrivateKeyPEM) if err != nil { - return true + return 0, true } - return cert.NeedsRenewal(cfg) + return time.Until(cert.NotAfter), cert.NeedsRenewal(cfg) } -// Manager is a type that can manage a certificate. -// They are usually very short-lived. -type Manager interface { - Obtain(ctx context.Context, name string) error - Renew(ctx context.Context, name string) error - Revoke(ctx context.Context, name string) error +func (cfg *Config) emit(eventName string, data interface{}) { + if cfg.OnEvent == nil { + return + } + cfg.OnEvent(eventName, data) } // CertificateSelector is a type which can select a certificate to use given multiple choices. type CertificateSelector interface { SelectCertificate(*tls.ClientHelloInfo, []Certificate) (Certificate, error) } + +// Constants for PKIX MustStaple extension. +var ( + tlsFeatureExtensionOID = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24} + ocspMustStapleFeature = []byte{0x30, 0x03, 0x02, 0x01, 0x05} + mustStapleExtension = pkix.Extension{ + Id: tlsFeatureExtensionOID, + Value: ocspMustStapleFeature, + } +) diff --git a/config_test.go b/config_test.go index 99f24477..4bc5ba7e 100644 --- a/config_test.go +++ b/config_test.go @@ -23,14 +23,15 @@ import ( ) func TestSaveCertResource(t *testing.T) { + am := &ACMEManager{CA: "https://example.com/acme/directory"} testConfig := &Config{ - CA: "https://example.com/acme/directory", + Issuer: am, Storage: &FileStorage{Path: "./_testdata_tmp"}, certCache: new(Cache), } + am.config = testConfig testStorageDir := testConfig.Storage.(*FileStorage).Path - defer func() { err := os.RemoveAll(testStorageDir) if err != nil { @@ -42,12 +43,15 @@ func TestSaveCertResource(t *testing.T) { certContents := "certificate" keyContents := "private key" - cert := &certificate.Resource{ - Domain: domain, - CertURL: "https://example.com/cert", - CertStableURL: "https://example.com/cert/stable", - PrivateKey: []byte(keyContents), - Certificate: []byte(certContents), + cert := CertificateResource{ + SANs: []string{domain}, + PrivateKeyPEM: []byte(keyContents), + CertificatePEM: []byte(certContents), + IssuerData: &certificate.Resource{ + Domain: domain, + CertURL: "https://example.com/cert", + CertStableURL: "https://example.com/cert/stable", + }, } err := testConfig.saveCertResource(cert) @@ -55,11 +59,19 @@ func TestSaveCertResource(t *testing.T) { t.Fatalf("Expected no error, got: %v", err) } + // the result of our test will be a map, since we have + // no choice but to decode it into an interface + cert.IssuerData = map[string]interface{}{ + "domain": domain, + "certUrl": "https://example.com/cert", + "certStableUrl": "https://example.com/cert/stable", + } + siteData, err := testConfig.loadCertResource(domain) if err != nil { t.Fatalf("Expected no error reading site, got: %v", err) } - if !reflect.DeepEqual(*cert, siteData) { + if !reflect.DeepEqual(cert, siteData) { t.Errorf("Expected '%+v' to match '%+v'", cert, siteData) } } diff --git a/crypto.go b/crypto.go index 0467bef2..1cc12e85 100644 --- a/crypto.go +++ b/crypto.go @@ -17,6 +17,9 @@ package certmagic import ( "crypto" "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/tls" @@ -25,8 +28,8 @@ import ( "encoding/pem" "fmt" "hash/fnv" + "strings" - "github.com/go-acme/lego/v3/certificate" "github.com/klauspost/cpuid" ) @@ -45,20 +48,48 @@ func encodePrivateKey(key crypto.PrivateKey) ([]byte, error) { case *rsa.PrivateKey: pemType = "RSA" keyBytes = x509.MarshalPKCS1PrivateKey(key) + case *ed25519.PrivateKey: + var err error + pemType = "ED25519" + keyBytes, err = x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unsupported key type: %T", key) } pemKey := pem.Block{Type: pemType + " PRIVATE KEY", Bytes: keyBytes} return pem.EncodeToMemory(&pemKey), nil } // decodePrivateKey loads a PEM-encoded ECC/RSA private key from an array of bytes. +// Borrowed from Go standard library, to handle various private key and PEM block types. +// https://github.com/golang/go/blob/693748e9fa385f1e2c3b91ca9acbb6c0ad2d133d/src/crypto/tls/tls.go#L291-L308 +// https://github.com/golang/go/blob/693748e9fa385f1e2c3b91ca9acbb6c0ad2d133d/src/crypto/tls/tls.go#L238) func decodePrivateKey(keyPEMBytes []byte) (crypto.PrivateKey, error) { - keyBlock, _ := pem.Decode(keyPEMBytes) - switch keyBlock.Type { - case "RSA PRIVATE KEY": - return x509.ParsePKCS1PrivateKey(keyBlock.Bytes) - case "EC PRIVATE KEY": - return x509.ParseECPrivateKey(keyBlock.Bytes) + keyBlockDER, _ := pem.Decode(keyPEMBytes) + + if keyBlockDER.Type != "PRIVATE KEY" && !strings.HasSuffix(keyBlockDER.Type, " PRIVATE KEY") { + return nil, fmt.Errorf("unknown PEM header %q", keyBlockDER.Type) + } + + if key, err := x509.ParsePKCS1PrivateKey(keyBlockDER.Bytes); err == nil { + return key, nil } + + if key, err := x509.ParsePKCS8PrivateKey(keyBlockDER.Bytes); err == nil { + switch key := key.(type) { + case *rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey: + return key, nil + default: + return nil, fmt.Errorf("found unknown private key type in PKCS#8 wrapping: %T", key) + } + } + + if key, err := x509.ParseECPrivateKey(keyBlockDER.Bytes); err == nil { + return key, nil + } + return nil, fmt.Errorf("unknown private key type") } @@ -98,23 +129,26 @@ func fastHash(input []byte) string { // saveCertResource saves the certificate resource to disk. This // includes the certificate file itself, the private key, and the // metadata file. -func (cfg *Config) saveCertResource(cert *certificate.Resource) error { - metaBytes, err := json.MarshalIndent(&cert, "", "\t") +func (cfg *Config) saveCertResource(cert CertificateResource) error { + metaBytes, err := json.MarshalIndent(cert, "", "\t") if err != nil { return fmt.Errorf("encoding certificate metadata: %v", err) } + issuerKey := cfg.Issuer.IssuerKey() + certKey := cert.NamesKey() + all := []keyValue{ { - key: StorageKeys.SiteCert(cfg.CA, cert.Domain), - value: cert.Certificate, + key: StorageKeys.SiteCert(issuerKey, certKey), + value: cert.CertificatePEM, }, { - key: StorageKeys.SitePrivateKey(cfg.CA, cert.Domain), - value: cert.PrivateKey, + key: StorageKeys.SitePrivateKey(issuerKey, certKey), + value: cert.PrivateKeyPEM, }, { - key: StorageKeys.SiteMeta(cfg.CA, cert.Domain), + key: StorageKeys.SiteMeta(issuerKey, certKey), value: metaBytes, }, } @@ -122,26 +156,27 @@ func (cfg *Config) saveCertResource(cert *certificate.Resource) error { return storeTx(cfg.Storage, all) } -func (cfg *Config) loadCertResource(domain string) (certificate.Resource, error) { - var certRes certificate.Resource - certBytes, err := cfg.Storage.Load(StorageKeys.SiteCert(cfg.CA, domain)) +func (cfg *Config) loadCertResource(certNamesKey string) (CertificateResource, error) { + var certRes CertificateResource + issuerKey := cfg.Issuer.IssuerKey() + certBytes, err := cfg.Storage.Load(StorageKeys.SiteCert(issuerKey, certNamesKey)) if err != nil { - return certRes, err + return CertificateResource{}, err } - keyBytes, err := cfg.Storage.Load(StorageKeys.SitePrivateKey(cfg.CA, domain)) + certRes.CertificatePEM = certBytes + keyBytes, err := cfg.Storage.Load(StorageKeys.SitePrivateKey(issuerKey, certNamesKey)) if err != nil { - return certRes, err + return CertificateResource{}, err } - metaBytes, err := cfg.Storage.Load(StorageKeys.SiteMeta(cfg.CA, domain)) + certRes.PrivateKeyPEM = keyBytes + metaBytes, err := cfg.Storage.Load(StorageKeys.SiteMeta(issuerKey, certNamesKey)) if err != nil { - return certRes, err + return CertificateResource{}, err } err = json.Unmarshal(metaBytes, &certRes) if err != nil { - return certRes, fmt.Errorf("decoding certificate metadata: %v", err) + return CertificateResource{}, fmt.Errorf("decoding certificate metadata: %v", err) } - certRes.Certificate = certBytes - certRes.PrivateKey = keyBytes return certRes, nil } @@ -156,6 +191,19 @@ func hashCertificateChain(certChain [][]byte) string { return fmt.Sprintf("%x", h.Sum(nil)) } +func namesFromCSR(csr *x509.CertificateRequest) []string { + var nameSet []string + nameSet = append(nameSet, csr.DNSNames...) + nameSet = append(nameSet, csr.EmailAddresses...) + for _, v := range csr.IPAddresses { + nameSet = append(nameSet, v.String()) + } + for _, v := range csr.URIs { + nameSet = append(nameSet, v.String()) + } + return nameSet +} + // preferredDefaultCipherSuites returns an appropriate // cipher suite to use depending on hardware support // for AES-NI. @@ -186,3 +234,46 @@ var ( tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, } ) + +// StandardKeyGenerator is the standard, in-memory key source +// that uses crypto/rand. +type StandardKeyGenerator struct { + // The type of keys to generate. + KeyType KeyType +} + +// GenerateKey generates a new private key according to kg.KeyType. +func (kg StandardKeyGenerator) GenerateKey() (crypto.PrivateKey, error) { + switch kg.KeyType { + case ED25519: + _, priv, err := ed25519.GenerateKey(rand.Reader) + return priv, err + case "", P256: + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case P384: + return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + case RSA2048: + return rsa.GenerateKey(rand.Reader, 2048) + case RSA4096: + return rsa.GenerateKey(rand.Reader, 4096) + case RSA8192: + return rsa.GenerateKey(rand.Reader, 8192) + } + return nil, fmt.Errorf("unrecognized or unsupported key type: %s", kg.KeyType) +} + +// DefaultKeyGenerator is the default key source. +var DefaultKeyGenerator = StandardKeyGenerator{KeyType: P256} + +// KeyType enumerates the known/supported key types. +type KeyType string + +// Constants for all key types we support. +const ( + ED25519 = KeyType("ed25519") + P256 = KeyType("p256") + P384 = KeyType("p384") + RSA2048 = KeyType("rsa2048") + RSA4096 = KeyType("rsa4096") + RSA8192 = KeyType("rsa8192") +) diff --git a/filestorage.go b/filestorage.go index 4d94b184..2beb2f50 100644 --- a/filestorage.go +++ b/filestorage.go @@ -162,7 +162,7 @@ func (fs *FileStorage) Lock(key string) error { case fileLockIsStale(meta): // lock file is stale - delete it and try again to create one log.Printf("[INFO][%s] Lock for '%s' is stale (created: %s, last update: %s); removing then retrying: %s", - fs, key, filename, meta.Created, meta.Updated) + fs, key, meta.Created, meta.Updated, filename) removeLockfile(filename) continue diff --git a/go.mod b/go.mod index 9b78a970..ce1a4f78 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/mholt/certmagic go 1.13 require ( - github.com/go-acme/lego/v3 v3.3.0 + github.com/go-acme/lego/v3 v3.3.1-0.20200221182807-5cdc0002e9ab github.com/klauspost/cpuid v1.2.0 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 ) diff --git a/go.sum b/go.sum index 0740d3ca..8fcf6aa9 100644 --- a/go.sum +++ b/go.sum @@ -41,8 +41,8 @@ github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cenkalti/backoff/v3 v3.0.0 h1:ske+9nBpD9qZsTBoF41nW5L+AIuFBKMeze18XQ3eG1c= -github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= +github.com/cenkalti/backoff/v4 v4.0.0 h1:6VeaLF9aI+MAUQ95106HwWzYZgJJpZ4stumjj6RFYAU= +github.com/cenkalti/backoff/v4 v4.0.0/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg= github.com/census-instrumentation/opencensus-proto v0.2.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/cloudflare-go v0.10.2/go.mod h1:qhVI5MKwBGhdNU89ZRz2plgYutcJ5PCekLxXn56w6SY= @@ -63,8 +63,8 @@ github.com/exoscale/egoscale v0.18.1/go.mod h1:Z7OOdzzTOz1Q1PjQXumlz9Wn/CddH0zSY github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-acme/lego/v3 v3.3.0 h1:6BePZsOiYA4/w+M7QDytxQtMfCipMPGnWAHs9pWks98= -github.com/go-acme/lego/v3 v3.3.0/go.mod h1:iGSY2vQrvQs3WezicSB/oVbO2eCrD88dpWPwb1qLqu0= +github.com/go-acme/lego/v3 v3.3.1-0.20200221182807-5cdc0002e9ab h1:qkf2919yn3CbZut92FEBEWshxORxlrNIHs5GLxxPWCY= +github.com/go-acme/lego/v3 v3.3.1-0.20200221182807-5cdc0002e9ab/go.mod h1:i8TqTdKW87iKNlpzasH6EepGgduF4ATnKW+qvs9aFJo= github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -151,7 +151,7 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04/go.mod h1:5sN+Lt1CaY4wsPvgQH/jsuJi4XO2ssZbdsIizr4CVC8= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/nrdcg/auroradns v1.0.0/go.mod h1:6JPXKzIRzZzMqtTDgueIhTi6rFf1QvYE/HzqidhOhjw= -github.com/nrdcg/dnspod-go v0.3.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= +github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= github.com/nrdcg/goinwx v0.6.1/go.mod h1:XPiut7enlbEdntAqalBIqcYcTEVhpv/dKWgDCX2SwKQ= github.com/nrdcg/namesilo v0.2.1/go.mod h1:lwMvfQTyYq+BbjJd30ylEG4GPSS6PII0Tia4rRpRiyw= github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= @@ -163,6 +163,7 @@ github.com/oracle/oci-go-sdk v7.0.0+incompatible/go.mod h1:VQb79nF8Z2cwLkLS35ukw github.com/ovh/go-ovh v0.0.0-20181109152953-ba5adb4cf014/go.mod h1:joRatxRJaZBsY3JAOEMcoOp05CnZzsx4scTxi95DHyQ= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/handshake.go b/handshake.go index d44f2339..e487076b 100644 --- a/handshake.go +++ b/handshake.go @@ -37,9 +37,7 @@ import ( // // This method is safe for use as a tls.Config.GetCertificate callback. func (cfg *Config) GetCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { - if cfg.OnEvent != nil { - cfg.OnEvent("tls_handshake_started", clientHello) - } + cfg.emit("tls_handshake_started", clientHello) // special case: serve up the certificate for a TLS-ALPN ACME challenge // (https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-05) @@ -70,8 +68,8 @@ func (cfg *Config) GetCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certif // get the certificate and serve it up cert, err := cfg.getCertDuringHandshake(clientHello, true, true) - if err == nil && cfg.OnEvent != nil { - cfg.OnEvent("tls_handshake_completed", clientHello) + if err == nil { + cfg.emit("tls_handshake_completed", clientHello) } return &cert.Certificate, err } @@ -155,7 +153,7 @@ func (cfg *Config) getCertificate(hello *tls.ClientHelloInfo) (cert Certificate, } // otherwise, we're bingo on ammo; see issues - // mholt/caddy#2035 and mholt/caddy#1303 (any + // caddyserver/caddy#2035 and caddyserver/caddy#1303 (any // change to certificate matching behavior must // account for hosts defined where the hostname // is empty or a catch-all, like ":443" or @@ -399,10 +397,15 @@ func (cfg *Config) renewDynamicCertificate(hello *tls.ClientHelloInfo, currentCe // tryDistributedChallengeSolver is to be called when the clientHello pertains to // a TLS-ALPN challenge and a certificate is required to solve it. This method // checks the distributed store of challenge info files and, if a matching ServerName -// is present, it makes a certificate to solve this challenge and returns it. +// is present, it makes a certificate to solve this challenge and returns it. For +// this to succeed, it requires that cfg.Issuer is of type *ACMEManager. // A boolean true is returned if a valid certificate is returned. func (cfg *Config) tryDistributedChallengeSolver(clientHello *tls.ClientHelloInfo) (Certificate, bool, error) { - tokenKey := distributedSolver{config: cfg}.challengeTokensKey(clientHello.ServerName) + am, ok := cfg.Issuer.(*ACMEManager) + if !ok { + return Certificate{}, false, nil + } + tokenKey := distributedSolver{acmeManager: am, caURL: am.CA}.challengeTokensKey(clientHello.ServerName) chalInfoBytes, err := cfg.Storage.Load(tokenKey) if err != nil { if _, ok := err.(ErrNotExist); ok { diff --git a/httphandler.go b/httphandler.go index 9be5b62b..856e723c 100644 --- a/httphandler.go +++ b/httphandler.go @@ -29,53 +29,53 @@ import ( // the challenge state is stored between initiation and solution. // // If a request is not an ACME HTTP challenge, h will be invoked. -func (cfg *Config) HTTPChallengeHandler(h http.Handler) http.Handler { +func (am *ACMEManager) HTTPChallengeHandler(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if cfg.HandleHTTPChallenge(w, r) { + if am.HandleHTTPChallenge(w, r) { return } h.ServeHTTP(w, r) }) } -// HandleHTTPChallenge uses cfg to solve challenge requests from an ACME +// HandleHTTPChallenge uses am to solve challenge requests from an ACME // server that were initiated by this instance or any other instance in -// this cluster (being, any instances using the same storage cfg does). +// this cluster (being, any instances using the same storage am does). // // If the HTTP challenge is disabled, this function is a no-op. // -// If cfg is nil or if cfg does not have a certificate cache backed by +// If am is nil or if am does not have a certificate cache backed by // usable storage, solving the HTTP challenge will fail. // // It returns true if it handled the request; if so, the response has // already been written. If false is returned, this call was a no-op and // the request has not been handled. -func (cfg *Config) HandleHTTPChallenge(w http.ResponseWriter, r *http.Request) bool { - if cfg == nil { +func (am *ACMEManager) HandleHTTPChallenge(w http.ResponseWriter, r *http.Request) bool { + if am == nil { return false } - if cfg.DisableHTTPChallenge { + if am.DisableHTTPChallenge { return false } if !LooksLikeHTTPChallenge(r) { return false } - return cfg.distributedHTTPChallengeSolver(w, r) + return am.distributedHTTPChallengeSolver(w, r) } // distributedHTTPChallengeSolver checks to see if this challenge // request was initiated by this or another instance which uses the -// same storage as cfg does, and attempts to complete the challenge for +// same storage as am does, and attempts to complete the challenge for // it. It returns true if the request was handled; false otherwise. -func (cfg *Config) distributedHTTPChallengeSolver(w http.ResponseWriter, r *http.Request) bool { - if cfg == nil { +func (am *ACMEManager) distributedHTTPChallengeSolver(w http.ResponseWriter, r *http.Request) bool { + if am == nil { return false } host := hostOnly(r.Host) - tokenKey := distributedSolver{config: cfg}.challengeTokensKey(host) - chalInfoBytes, err := cfg.Storage.Load(tokenKey) + tokenKey := distributedSolver{acmeManager: am, caURL: am.CA}.challengeTokensKey(host) + chalInfoBytes, err := am.config.Storage.Load(tokenKey) if err != nil { if _, ok := err.(ErrNotExist); !ok { log.Printf("[ERROR][%s] Opening distributed HTTP challenge token file: %v", host, err) diff --git a/httphandler_test.go b/httphandler_test.go index 30aa6f6a..08841bf8 100644 --- a/httphandler_test.go +++ b/httphandler_test.go @@ -22,12 +22,21 @@ import ( ) func TestHTTPChallengeHandlerNoOp(t *testing.T) { + am := &ACMEManager{CA: "https://example.com/acme/directory"} testConfig := &Config{ - CA: "https://example.com/acme/directory", - Storage: &FileStorage{Path: "./_testdata_tmp"}, + Issuer: am, + Storage: &FileStorage{Path: "./_testdata_tmp"}, + certCache: new(Cache), } + am.config = testConfig + testStorageDir := testConfig.Storage.(*FileStorage).Path - defer os.RemoveAll(testStorageDir) + defer func() { + err := os.RemoveAll(testStorageDir) + if err != nil { + t.Fatalf("Could not remove temporary storage directory (%s): %v", testStorageDir, err) + } + }() // try base paths and host names that aren't // handled by this handler @@ -44,7 +53,7 @@ func TestHTTPChallengeHandlerNoOp(t *testing.T) { t.Fatalf("Could not craft request, got error: %v", err) } rw := httptest.NewRecorder() - if testConfig.HandleHTTPChallenge(rw, req) { + if am.HandleHTTPChallenge(rw, req) { t.Errorf("Got true with this URL, but shouldn't have: %s", url) } } diff --git a/maintain.go b/maintain.go index 486ad94c..b54926e8 100644 --- a/maintain.go +++ b/maintain.go @@ -57,7 +57,7 @@ func (certCache *Cache) maintainAssets() { case <-certCache.stopChan: renewalTicker.Stop() ocspTicker.Stop() - // TODO: stop any in-progress maintenance operations and clear locks we made + // TODO: stop any in-progress maintenance operations and clear locks we made (this might be done now with our use of context) log.Printf("[INFO][cache:%p] Stopped certificate maintenance routine", certCache) close(certCache.doneChan) return @@ -140,7 +140,7 @@ func (certCache *Cache) RenewManagedCertificates(ctx context.Context) error { // Reload certificates that merely need to be updated in memory for _, oldCert := range reloadQueue { timeLeft := oldCert.NotAfter.Sub(time.Now().UTC()) - log.Printf("[INFO] Certificate for %v expires in %v, but is already renewed in storage; reloading stored certificate", + log.Printf("[INFO] %v Maintenance routine: certificate expires in %v, but is already renewed in storage; reloading stored certificate", oldCert.Names, timeLeft) cfg := configs[oldCert.Names[0]] @@ -155,42 +155,58 @@ func (certCache *Cache) RenewManagedCertificates(ctx context.Context) error { // Renewal queue for _, oldCert := range renewQueue { - timeLeft := oldCert.NotAfter.Sub(time.Now().UTC()) - log.Printf("[INFO] Certificate for %v expires in %v; attempting renewal", oldCert.Names, timeLeft) - cfg := configs[oldCert.Names[0]] + err := certCache.queueRenewalTask(ctx, oldCert, cfg) + if err != nil { + log.Printf("[ERROR] %v", err) + continue + } + } - // Get the name which we should use to renew this certificate; - // we only support managing certificates with one name per cert, - // so this should be easy. - renewName := oldCert.Names[0] + // Deletion queue + certCache.mu.Lock() + for _, cert := range deleteQueue { + certCache.removeCertificate(cert) + } + certCache.mu.Unlock() + + return nil +} + +func (certCache *Cache) queueRenewalTask(ctx context.Context, oldCert Certificate, cfg *Config) error { + timeLeft := oldCert.NotAfter.Sub(time.Now().UTC()) + log.Printf("[INFO] %v Maintenance routine: certificate expires in %v; queueing for renewal", oldCert.Names, timeLeft) + + // Get the name which we should use to renew this certificate; + // we only support managing certificates with one name per cert, + // so this should be easy. + renewName := oldCert.Names[0] + + // queue up this renewal job (is a no-op if already active or queued) + jm.Submit("renew_"+renewName, func() error { + timeLeft := oldCert.NotAfter.Sub(time.Now().UTC()) + log.Printf("[INFO] %v Maintenance routine: attempting renewal with %v remaining", oldCert.Names, timeLeft) // perform renewal - crucially, this happens OUTSIDE a lock on certCache err := cfg.RenewCert(ctx, renewName, false) if err != nil { - log.Printf("[ERROR][%s] %v", renewName, err) if cfg.OnDemand != nil { // loaded dynamically, remove dynamically - deleteQueue = append(deleteQueue, oldCert) + certCache.mu.Lock() + certCache.removeCertificate(oldCert) + certCache.mu.Unlock() } - continue + return fmt.Errorf("%v %v", oldCert.Names, err) } // successful renewal, so update in-memory cache by loading // renewed certificate so it will be used with handshakes err = cfg.reloadManagedCertificate(oldCert) if err != nil { - log.Printf("[ERROR][%s] %v", renewName, err) - continue + return ErrNoRetry{fmt.Errorf("%v %v", oldCert.Names, err)} } - } - - // Deletion queue - certCache.mu.Lock() - for _, cert := range deleteQueue { - certCache.removeCertificate(cert) - } - certCache.mu.Unlock() + return nil + }) return nil } @@ -298,6 +314,7 @@ func (certCache *Cache) updateOCSPStaples(ctx context.Context) { renewName := oldCert.Names[0] cfg := configs[renewName] + // TODO: consider using a new key in this situation err := cfg.RenewCert(ctx, renewName, false) if err != nil { // probably better to not serve a revoked certificate at all @@ -307,7 +324,6 @@ func (certCache *Cache) updateOCSPStaples(ctx context.Context) { certCache.mu.Unlock() continue } - err = cfg.reloadManagedCertificate(oldCert) if err != nil { log.Printf("[ERROR] After obtaining new certificate due to OCSP status of revoked: %v", err) diff --git a/solvers.go b/solvers.go index 957682f5..0a405872 100644 --- a/solvers.go +++ b/solvers.go @@ -21,9 +21,10 @@ import ( "log" "net" "net/http" - "path/filepath" + "path" "sync" "sync/atomic" + "time" "github.com/go-acme/lego/v3/challenge" "github.com/go-acme/lego/v3/challenge/tlsalpn01" @@ -40,9 +41,9 @@ import ( // can access the keyAuth material is by loading it // from storage, which is done by distributedSolver. type httpSolver struct { - closed int32 // accessed atomically - config *Config - address string + closed int32 // accessed atomically + acmeManager *ACMEManager + address string } // Present starts an HTTP server if none is already listening on s.address. @@ -77,7 +78,7 @@ func (s *httpSolver) Present(domain, token, keyAuth string) error { // serve is an HTTP server that serves only HTTP challenge responses. func (s *httpSolver) serve(si *solverInfo) { - httpServer := &http.Server{Handler: s.config.HTTPChallengeHandler(http.NewServeMux())} + httpServer := &http.Server{Handler: s.acmeManager.HTTPChallengeHandler(http.NewServeMux())} httpServer.SetKeepAlivesEnabled(false) err := httpServer.Serve(si.listener) if err != nil && atomic.LoadInt32(&s.closed) != 1 { @@ -108,7 +109,7 @@ func (s *httpSolver) CleanUp(domain, token, keyAuth string) error { // It must have an associated config and address on which to // serve the challenge. type tlsALPNSolver struct { - closed int32 // accessed atomically + // closed int32 // accessed atomically config *Config address string } @@ -163,7 +164,7 @@ func (s *tlsALPNSolver) Present(domain, token, keyAuth string) error { for { conn, err := si.listener.Accept() if err != nil { - if atomic.LoadInt32(&s.closed) == 1 { + if atomic.LoadInt32(&si.closed) == 1 { return } log.Printf("[ERROR] TLS-ALPN challenge server: accept: %v", err) @@ -204,7 +205,7 @@ func (s *tlsALPNSolver) CleanUp(domain, token, keyAuth string) error { si.count-- if si.count == 0 { // last one out turns off the lights - atomic.StoreInt32(&s.closed, 1) + atomic.StoreInt32(&si.closed, 1) if si.listener != nil { si.listener.Close() } @@ -249,17 +250,21 @@ type distributedSolver struct { // with a reference to the storage to // use which is shared among all the // instances in the cluster - REQUIRED. - config *Config + acmeManager *ACMEManager // Since the distributedSolver is only a // wrapper over an actual solver, place // the actual solver here. providerServer challenge.Provider + + // The CA endpoint URL associated with + // this solver. + caURL string } // Present invokes the underlying solver's Present method // and also stores domain, token, and keyAuth to the storage -// backing the certificate cache of dhs.config. +// backing the certificate cache of dhs.acmeManager. func (dhs distributedSolver) Present(domain, token, keyAuth string) error { infoBytes, err := json.Marshal(challengeInfo{ Domain: domain, @@ -270,7 +275,7 @@ func (dhs distributedSolver) Present(domain, token, keyAuth string) error { return err } - err = dhs.config.Storage.Store(dhs.challengeTokensKey(domain), infoBytes) + err = dhs.acmeManager.config.Storage.Store(dhs.challengeTokensKey(domain), infoBytes) if err != nil { return err } @@ -285,7 +290,7 @@ func (dhs distributedSolver) Present(domain, token, keyAuth string) error { // CleanUp invokes the underlying solver's CleanUp method // and also cleans up any assets saved to storage. func (dhs distributedSolver) CleanUp(domain, token, keyAuth string) error { - err := dhs.config.Storage.Delete(dhs.challengeTokensKey(domain)) + err := dhs.acmeManager.config.Storage.Delete(dhs.challengeTokensKey(domain)) if err != nil { return err } @@ -298,13 +303,13 @@ func (dhs distributedSolver) CleanUp(domain, token, keyAuth string) error { // challengeTokensPrefix returns the key prefix for challenge info. func (dhs distributedSolver) challengeTokensPrefix() string { - return filepath.Join(StorageKeys.CAPrefix(dhs.config.CA), "challenge_tokens") + return path.Join(dhs.acmeManager.storageKeyCAPrefix(dhs.caURL), "challenge_tokens") } // challengeTokensKey returns the key to use to store and access // challenge info for domain. func (dhs distributedSolver) challengeTokensKey(domain string) string { - return filepath.Join(dhs.challengeTokensPrefix(), StorageKeys.Safe(domain)+".json") + return path.Join(dhs.challengeTokensPrefix(), StorageKeys.Safe(domain)+".json") } type challengeInfo struct { @@ -314,6 +319,7 @@ type challengeInfo struct { // solverInfo associates a listener with the // number of challenges currently using it. type solverInfo struct { + closed int32 // accessed atomically count int listener net.Listener done chan struct{} // used to signal when cleanup is finished @@ -329,6 +335,16 @@ func getSolverInfo(address string) *solverInfo { return si } +// listenerAddressInUse returns true if a TCP connection +// can be made to addr within a short time interval. +func listenerAddressInUse(addr string) bool { + conn, err := net.DialTimeout("tcp", addr, 250*time.Millisecond) + if err == nil { + conn.Close() + } + return err == nil +} + // The active challenge solvers, keyed by listener address, // and protected by a mutex. Note that the creation of // solver listeners and the incrementing of their counts diff --git a/storage.go b/storage.go index 021d96e7..c24e6e3b 100644 --- a/storage.go +++ b/storage.go @@ -16,7 +16,6 @@ package certmagic import ( "log" - "net/url" "path" "regexp" "strings" @@ -82,7 +81,7 @@ type Locker interface { // To prevent deadlocks, all implementations (where this concern // is relevant) should put a reasonable expiration on the lock in // case Unlock is unable to be called due to some sort of network - // or system failure or crash. + // failure or system crash. Lock(key string) error // Unlock releases the lock for key. This method must ONLY be @@ -93,6 +92,12 @@ type Locker interface { } // KeyInfo holds information about a key in storage. +// Key and IsTerminal are required; Modified and Size +// are optional if the storage implementation is not +// able to get that information. Setting them will +// make certain operations more consistent or +// predictable, but it is not crucial to basic +// functionality. type KeyInfo struct { Key string Modified time.Time @@ -125,62 +130,39 @@ type keyValue struct { // in a Storage implementation. type KeyBuilder struct{} -// CAPrefix returns the storage key prefix for -// the given certificate authority URL. -func (keys KeyBuilder) CAPrefix(ca string) string { - caURL, err := url.Parse(ca) - if err != nil { - caURL = &url.URL{Host: ca} - } - return path.Join(prefixACME, keys.Safe(caURL.Host)) -} - -// SitePrefix returns a key prefix for items associated with -// the site using the given CA URL. -func (keys KeyBuilder) SitePrefix(ca, domain string) string { - return path.Join(keys.CAPrefix(ca), "sites", keys.Safe(domain)) +// CertsPrefix returns the storage key prefix for +// the given certificate issuer. +func (keys KeyBuilder) CertsPrefix(issuerKey string) string { + return path.Join(prefixCerts, keys.Safe(issuerKey)) } -// SiteCert returns the path to the certificate file for domain. -func (keys KeyBuilder) SiteCert(ca, domain string) string { - return path.Join(keys.SitePrefix(ca, domain), keys.Safe(domain)+".crt") +// CertsSitePrefix returns a key prefix for items associated with +// the site given by domain using the given issuer key. +func (keys KeyBuilder) CertsSitePrefix(issuerKey, domain string) string { + return path.Join(keys.CertsPrefix(issuerKey), keys.Safe(domain)) } -// SitePrivateKey returns the path to domain's private key file. -func (keys KeyBuilder) SitePrivateKey(ca, domain string) string { - return path.Join(keys.SitePrefix(ca, domain), keys.Safe(domain)+".key") +// SiteCert returns the path to the certificate file for domain +// that is associated with the issuer with the given issuerKey. +func (keys KeyBuilder) SiteCert(issuerKey, domain string) string { + safeDomain := keys.Safe(domain) + return path.Join(keys.CertsSitePrefix(issuerKey, domain), safeDomain+".crt") } -// SiteMeta returns the path to the domain's asset metadata file. -func (keys KeyBuilder) SiteMeta(ca, domain string) string { - return path.Join(keys.SitePrefix(ca, domain), keys.Safe(domain)+".json") +// SitePrivateKey returns the path to the private key file for domain +// that is associated with the certificate from the given issuer with +// the given issuerKey. +func (keys KeyBuilder) SitePrivateKey(issuerKey, domain string) string { + safeDomain := keys.Safe(domain) + return path.Join(keys.CertsSitePrefix(issuerKey, domain), safeDomain+".key") } -// UsersPrefix returns a key prefix for items related to -// users associated with the given CA URL. -func (keys KeyBuilder) UsersPrefix(ca string) string { - return path.Join(keys.CAPrefix(ca), "users") -} - -// UserPrefix returns a key prefix for items related to -// the user with the given email for the given CA URL. -func (keys KeyBuilder) UserPrefix(ca, email string) string { - if email == "" { - email = emptyEmail - } - return path.Join(keys.UsersPrefix(ca), keys.Safe(email)) -} - -// UserReg gets the path to the registration file for the user -// with the given email address for the given CA URL. -func (keys KeyBuilder) UserReg(ca, email string) string { - return keys.safeUserKey(ca, email, "registration", ".json") -} - -// UserPrivateKey gets the path to the private key file for the -// user with the given email address on the given CA URL. -func (keys KeyBuilder) UserPrivateKey(ca, email string) string { - return keys.safeUserKey(ca, email, "private", ".key") +// SiteMeta returns the path to the metadata file for domain that +// is associated with the certificate from the given issuer with +// the given issuerKey. +func (keys KeyBuilder) SiteMeta(issuerKey, domain string) string { + safeDomain := keys.Safe(domain) + return path.Join(keys.CertsSitePrefix(issuerKey, domain), safeDomain+".json") } // OCSPStaple returns a key for the OCSP staple associated @@ -196,35 +178,11 @@ func (keys KeyBuilder) OCSPStaple(cert *Certificate, pemBundle []byte) string { return path.Join(prefixOCSP, ocspFileName) } -// safeUserKey returns a key for the given email, with the default -// filename, and the filename ending in the given extension. -func (keys KeyBuilder) safeUserKey(ca, email, defaultFilename, extension string) string { - if email == "" { - email = emptyEmail - } - email = strings.ToLower(email) - filename := keys.emailUsername(email) - if filename == "" { - filename = defaultFilename - } - filename = keys.Safe(filename) - return path.Join(keys.UserPrefix(ca, email), filename+extension) -} - -// emailUsername returns the username portion of an email address (part before -// '@') or the original input if it can't find the "@" symbol. -func (keys KeyBuilder) emailUsername(email string) string { - at := strings.Index(email, "@") - if at == -1 { - return email - } else if at == 0 { - return email[1:] - } - return email[:at] -} - // Safe standardizes and sanitizes str for use as -// a storage key. This method is idempotent. +// a storage key. This method is idempotent. It +// accepts multiple components of a key, in other +// words, str may have a separator (forward slash) +// such as "a/b/c" as this does not replace slashes. func (keys KeyBuilder) Safe(str string) string { str = strings.ToLower(str) str = strings.TrimSpace(str) @@ -234,6 +192,7 @@ func (keys KeyBuilder) Safe(str string) string { " ", "_", "+", "_plus_", "*", "wildcard_", + ":", "-", "..", "", // prevent directory traversal (regex allows single dots) ) str = repl.Replace(str) @@ -247,7 +206,6 @@ func (keys KeyBuilder) Safe(str string) string { // this does not cancel the operations that // the locks are synchronizing, this should be // called only immediately before process exit. -// TODO: have a way to properly cancel the active operations func CleanUpOwnLocks() { locksMu.Lock() defer locksMu.Unlock() @@ -296,8 +254,8 @@ var locksMu sync.Mutex var StorageKeys KeyBuilder const ( - prefixACME = "acme" - prefixOCSP = "ocsp" + prefixCerts = "certificates" + prefixOCSP = "ocsp" ) // safeKeyRE matches any undesirable characters in storage keys. diff --git a/storage_test.go b/storage_test.go index 051af0cf..b7ffbb35 100644 --- a/storage_test.go +++ b/storage_test.go @@ -20,59 +20,58 @@ import ( ) func TestPrefixAndKeyBuilders(t *testing.T) { - const ca = "https://example.com/acme-ca/directory" + am := &ACMEManager{CA: "https://example.com/acme-ca/directory"} + + base := path.Join("certificates", "example.com-acme-ca-directory") for i, testcase := range []struct { in, folder, certFile, keyFile, metaFile string }{ { in: "example.com", - folder: path.Join("acme", "example.com", "sites", "example.com"), - certFile: path.Join("acme", "example.com", "sites", "example.com", "example.com.crt"), - keyFile: path.Join("acme", "example.com", "sites", "example.com", "example.com.key"), - metaFile: path.Join("acme", "example.com", "sites", "example.com", "example.com.json"), + folder: path.Join(base, "example.com"), + certFile: path.Join(base, "example.com", "example.com.crt"), + keyFile: path.Join(base, "example.com", "example.com.key"), + metaFile: path.Join(base, "example.com", "example.com.json"), }, { in: "*.example.com", - folder: path.Join("acme", "example.com", "sites", "wildcard_.example.com"), - certFile: path.Join("acme", "example.com", "sites", "wildcard_.example.com", "wildcard_.example.com.crt"), - keyFile: path.Join("acme", "example.com", "sites", "wildcard_.example.com", "wildcard_.example.com.key"), - metaFile: path.Join("acme", "example.com", "sites", "wildcard_.example.com", "wildcard_.example.com.json"), + folder: path.Join(base, "wildcard_.example.com"), + certFile: path.Join(base, "wildcard_.example.com", "wildcard_.example.com.crt"), + keyFile: path.Join(base, "wildcard_.example.com", "wildcard_.example.com.key"), + metaFile: path.Join(base, "wildcard_.example.com", "wildcard_.example.com.json"), }, { // prevent directory traversal! very important, esp. with on-demand TLS // see issue #2092 in: "a/../../../foo", - folder: path.Join("acme", "example.com", "sites", "afoo"), - certFile: path.Join("acme", "example.com", "sites", "afoo", "afoo.crt"), - keyFile: path.Join("acme", "example.com", "sites", "afoo", "afoo.key"), - metaFile: path.Join("acme", "example.com", "sites", "afoo", "afoo.json"), + folder: path.Join(base, "afoo"), + certFile: path.Join(base, "afoo", "afoo.crt"), + keyFile: path.Join(base, "afoo", "afoo.key"), + metaFile: path.Join(base, "afoo", "afoo.json"), }, { in: "b\\..\\..\\..\\foo", - folder: path.Join("acme", "example.com", "sites", "bfoo"), - certFile: path.Join("acme", "example.com", "sites", "bfoo", "bfoo.crt"), - keyFile: path.Join("acme", "example.com", "sites", "bfoo", "bfoo.key"), - metaFile: path.Join("acme", "example.com", "sites", "bfoo", "bfoo.json"), + folder: path.Join(base, "bfoo"), + certFile: path.Join(base, "bfoo", "bfoo.crt"), + keyFile: path.Join(base, "bfoo", "bfoo.key"), + metaFile: path.Join(base, "bfoo", "bfoo.json"), }, { in: "c/foo", - folder: path.Join("acme", "example.com", "sites", "cfoo"), - certFile: path.Join("acme", "example.com", "sites", "cfoo", "cfoo.crt"), - keyFile: path.Join("acme", "example.com", "sites", "cfoo", "cfoo.key"), - metaFile: path.Join("acme", "example.com", "sites", "cfoo", "cfoo.json"), + folder: path.Join(base, "cfoo"), + certFile: path.Join(base, "cfoo", "cfoo.crt"), + keyFile: path.Join(base, "cfoo", "cfoo.key"), + metaFile: path.Join(base, "cfoo", "cfoo.json"), }, } { - if actual := StorageKeys.SitePrefix(ca, testcase.in); actual != testcase.folder { - t.Errorf("Test %d: site folder: Expected '%s' but got '%s'", i, testcase.folder, actual) - } - if actual := StorageKeys.SiteCert(ca, testcase.in); actual != testcase.certFile { + if actual := StorageKeys.SiteCert(am.IssuerKey(), testcase.in); actual != testcase.certFile { t.Errorf("Test %d: site cert file: Expected '%s' but got '%s'", i, testcase.certFile, actual) } - if actual := StorageKeys.SitePrivateKey(ca, testcase.in); actual != testcase.keyFile { + if actual := StorageKeys.SitePrivateKey(am.IssuerKey(), testcase.in); actual != testcase.keyFile { t.Errorf("Test %d: site key file: Expected '%s' but got '%s'", i, testcase.keyFile, actual) } - if actual := StorageKeys.SiteMeta(ca, testcase.in); actual != testcase.metaFile { + if actual := StorageKeys.SiteMeta(am.IssuerKey(), testcase.in); actual != testcase.metaFile { t.Errorf("Test %d: site meta file: Expected '%s' but got '%s'", i, testcase.metaFile, actual) } } diff --git a/user.go b/user.go index b8bb41a4..d49bf1e0 100644 --- a/user.go +++ b/user.go @@ -60,7 +60,7 @@ func (u user) GetPrivateKey() crypto.PrivateKey { // user to disk or register it via ACME. If you want to use // a user account that might already exist, call getUser // instead. It does NOT prompt the user. -func (cfg *Config) newUser(email string) (*user, error) { +func (*ACMEManager) newUser(email string) (*user, error) { user := &user{Email: email} privateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) if err != nil { @@ -77,47 +77,47 @@ func (cfg *Config) newUser(email string) (*user, error) { // the consequences of an empty email.) This function MAY prompt // the user for input. If allowPrompts is false, the user // will NOT be prompted and an empty email may be returned. -func (cfg *Config) getEmail(allowPrompts bool) error { - leEmail := cfg.Email +func (am *ACMEManager) getEmail(allowPrompts bool) error { + leEmail := am.Email // First try package default email if leEmail == "" { - leEmail = Default.Email // TODO: racey with line 108 + leEmail = DefaultACME.Email // TODO: racey with line 108 } // Then try to get most recent user email from storage var gotRecentEmail bool if leEmail == "" { - leEmail, gotRecentEmail = cfg.mostRecentUserEmail() + leEmail, gotRecentEmail = am.mostRecentUserEmail(am.CA) } if !gotRecentEmail && leEmail == "" && allowPrompts { // Looks like there is no email address readily available, // so we will have to ask the user if we can. var err error - leEmail, err = cfg.promptUserForEmail() + leEmail, err = am.promptUserForEmail() if err != nil { return err } // User might have just signified their agreement - cfg.Agreed = Default.Agreed + am.Agreed = DefaultACME.Agreed } // save the email for later and ensure it is consistent // for repeated use; then update cfg with the email - Default.Email = strings.TrimSpace(strings.ToLower(leEmail)) // TODO: this is racey with line 85 - cfg.Email = Default.Email + DefaultACME.Email = strings.TrimSpace(strings.ToLower(leEmail)) // TODO: this is racey with line 85 + am.Email = DefaultACME.Email return nil } -func (cfg *Config) getAgreementURL() (string, error) { +func (am *ACMEManager) getAgreementURL() (string, error) { if agreementTestURL != "" { return agreementTestURL, nil } - caURL := Default.CA - if cfg.CA != "" { - caURL = cfg.CA + caURL := am.CA + if caURL == "" { + caURL = DefaultACME.CA } response, err := http.Get(caURL) if err != nil { @@ -137,14 +137,14 @@ func (cfg *Config) getAgreementURL() (string, error) { // be the empty string). If no error is returned, then Agreed // will also be set to true, since continuing through the // prompt signifies agreement. -func (cfg *Config) promptUserForEmail() (string, error) { - agreementURL, err := cfg.getAgreementURL() +func (am *ACMEManager) promptUserForEmail() (string, error) { + agreementURL, err := am.getAgreementURL() if err != nil { return "", fmt.Errorf("get Agreement URL: %v", err) } // prompt the user for an email address and terms agreement reader := bufio.NewReader(stdin) - cfg.promptUserAgreement(agreementURL) + am.promptUserAgreement(agreementURL) fmt.Println("Please enter your email address to signify agreement and to be notified") fmt.Println("in case of issues. You can leave it blank, but we don't recommend it.") fmt.Print(" Email address: ") @@ -153,7 +153,7 @@ func (cfg *Config) promptUserForEmail() (string, error) { return "", fmt.Errorf("reading email address: %v", err) } leEmail = strings.TrimSpace(leEmail) - Default.Agreed = true + DefaultACME.Agreed = true return leEmail, nil } @@ -162,20 +162,20 @@ func (cfg *Config) promptUserForEmail() (string, error) { // it will create a new one, but it does NOT save new // users to the disk or register them via ACME. It does // NOT prompt the user. -func (cfg *Config) getUser(email string) (*user, error) { - regBytes, err := cfg.Storage.Load(StorageKeys.UserReg(cfg.CA, email)) +func (am *ACMEManager) getUser(ca, email string) (*user, error) { + regBytes, err := am.config.Storage.Load(am.storageKeyUserReg(ca, email)) if err != nil { if _, ok := err.(ErrNotExist); ok { // create a new user - return cfg.newUser(email) + return am.newUser(email) } return nil, err } - keyBytes, err := cfg.Storage.Load(StorageKeys.UserPrivateKey(cfg.CA, email)) + keyBytes, err := am.config.Storage.Load(am.storageKeyUserPrivateKey(ca, email)) if err != nil { if _, ok := err.(ErrNotExist); ok { // create a new user - return cfg.newUser(email) + return am.newUser(email) } return nil, err } @@ -194,7 +194,7 @@ func (cfg *Config) getUser(email string) (*user, error) { // or prompt the user. You must also pass in the storage // wherein the user should be saved. It should be the storage // for the CA with which user has an account. -func (cfg *Config) saveUser(user *user) error { +func (am *ACMEManager) saveUser(ca string, user *user) error { regBytes, err := json.MarshalIndent(&user, "", "\t") if err != nil { return err @@ -203,25 +203,23 @@ func (cfg *Config) saveUser(user *user) error { if err != nil { return err } - all := []keyValue{ { - key: StorageKeys.UserReg(cfg.CA, user.Email), + key: am.storageKeyUserReg(ca, user.Email), value: regBytes, }, { - key: StorageKeys.UserPrivateKey(cfg.CA, user.Email), + key: am.storageKeyUserPrivateKey(ca, user.Email), value: keyBytes, }, } - - return storeTx(cfg.Storage, all) + return storeTx(am.config.Storage, all) } // promptUserAgreement simply outputs the standard user // agreement prompt with the given agreement URL. // It outputs a newline after the message. -func (cfg *Config) promptUserAgreement(agreementURL string) { +func (am *ACMEManager) promptUserAgreement(agreementURL string) { const userAgreementPrompt = `Your sites will be served over HTTPS automatically using Let's Encrypt. By continuing, you agree to the Let's Encrypt Subscriber Agreement at:` fmt.Printf("\n\n%s\n %s\n", userAgreementPrompt, agreementURL) @@ -230,8 +228,8 @@ By continuing, you agree to the Let's Encrypt Subscriber Agreement at:` // askUserAgreement prompts the user to agree to the agreement // at the given agreement URL via stdin. It returns whether the // user agreed or not. -func (cfg *Config) askUserAgreement(agreementURL string) bool { - cfg.promptUserAgreement(agreementURL) +func (am *ACMEManager) askUserAgreement(agreementURL string) bool { + am.promptUserAgreement(agreementURL) fmt.Print("Do you agree to the terms? (y/n): ") reader := bufio.NewReader(stdin) @@ -244,21 +242,71 @@ func (cfg *Config) askUserAgreement(agreementURL string) bool { return answer == "y" || answer == "yes" } +func (am *ACMEManager) storageKeyCAPrefix(caURL string) string { + return path.Join(prefixACME, StorageKeys.Safe(am.issuerKey(caURL))) +} + +func (am *ACMEManager) storageKeyUsersPrefix(caURL string) string { + return path.Join(am.storageKeyCAPrefix(caURL), "users") +} + +func (am *ACMEManager) storageKeyUserPrefix(caURL, email string) string { + if email == "" { + email = emptyEmail + } + return path.Join(am.storageKeyUsersPrefix(caURL), StorageKeys.Safe(email)) +} + +func (am *ACMEManager) storageKeyUserReg(caURL, email string) string { + return am.storageSafeUserKey(caURL, email, "registration", ".json") +} + +func (am *ACMEManager) storageKeyUserPrivateKey(caURL, email string) string { + return am.storageSafeUserKey(caURL, email, "private", ".key") +} + +// storageSafeUserKey returns a key for the given email, with the default +// filename, and the filename ending in the given extension. +func (am *ACMEManager) storageSafeUserKey(ca, email, defaultFilename, extension string) string { + if email == "" { + email = emptyEmail + } + email = strings.ToLower(email) + filename := am.emailUsername(email) + if filename == "" { + filename = defaultFilename + } + filename = StorageKeys.Safe(filename) + return path.Join(am.storageKeyUserPrefix(ca, email), filename+extension) +} + +// emailUsername returns the username portion of an email address (part before +// '@') or the original input if it can't find the "@" symbol. +func (*ACMEManager) emailUsername(email string) string { + at := strings.Index(email, "@") + if at == -1 { + return email + } else if at == 0 { + return email[1:] + } + return email[:at] +} + // mostRecentUserEmail finds the most recently-written user file -// in s. Since this is part of a complex sequence to get a user +// in storage. Since this is part of a complex sequence to get a user // account, errors here are discarded to simplify code flow in // the caller, and errors are not important here anyway. -func (cfg *Config) mostRecentUserEmail() (string, bool) { - userList, err := cfg.Storage.List(StorageKeys.UsersPrefix(cfg.CA), false) +func (am *ACMEManager) mostRecentUserEmail(caURL string) (string, bool) { + userList, err := am.config.Storage.List(am.storageKeyUsersPrefix(caURL), false) if err != nil || len(userList) == 0 { return "", false } sort.Slice(userList, func(i, j int) bool { - iInfo, _ := cfg.Storage.Stat(userList[i]) - jInfo, _ := cfg.Storage.Stat(userList[j]) + iInfo, _ := am.config.Storage.Stat(userList[i]) + jInfo, _ := am.config.Storage.Stat(userList[j]) return jInfo.Modified.Before(iInfo.Modified) }) - user, err := cfg.getUser(path.Base(userList[0])) + user, err := am.getUser(caURL, path.Base(userList[0])) if err != nil { return "", false } diff --git a/user_test.go b/user_test.go index f6c26105..4bf8d56c 100644 --- a/user_test.go +++ b/user_test.go @@ -50,14 +50,16 @@ func TestUser(t *testing.T) { } } func TestNewUser(t *testing.T) { + am := &ACMEManager{CA: dummyCA} testConfig := &Config{ - CA: "https://example.com/acme/directory", + Issuer: am, Storage: &FileStorage{Path: "./_testdata_tmp"}, certCache: new(Cache), } + am.config = testConfig email := "me@foobar.com" - user, err := testConfig.newUser(email) + user, err := am.newUser(email) if err != nil { t.Fatalf("Error creating user: %v", err) } @@ -73,38 +75,48 @@ func TestNewUser(t *testing.T) { } func TestSaveUser(t *testing.T) { + am := &ACMEManager{CA: dummyCA} testConfig := &Config{ - CA: "https://example.com/acme/directory", + Issuer: am, Storage: &FileStorage{Path: "./_testdata1_tmp"}, certCache: new(Cache), } + am.config = testConfig + testStorageDir := testConfig.Storage.(*FileStorage).Path - defer os.RemoveAll(testStorageDir) + defer func() { + err := os.RemoveAll(testStorageDir) + if err != nil { + t.Fatalf("Could not remove temporary storage directory (%s): %v", testStorageDir, err) + } + }() email := "me@foobar.com" - user, err := testConfig.newUser(email) + user, err := am.newUser(email) if err != nil { t.Fatalf("Error creating user: %v", err) } - err = testConfig.saveUser(user) + err = am.saveUser(am.CA, user) if err != nil { t.Fatalf("Error saving user: %v", err) } - _, err = testConfig.getUser(email) + _, err = am.getUser(am.CA, email) if err != nil { t.Errorf("Cannot access user data, error: %v", err) } } func TestGetUserDoesNotAlreadyExist(t *testing.T) { + am := &ACMEManager{CA: dummyCA} testConfig := &Config{ - CA: "https://example.com/acme/directory", + Issuer: am, Storage: &FileStorage{Path: "./_testdata_tmp"}, certCache: new(Cache), } + am.config = testConfig - user, err := testConfig.getUser("user_does_not_exist@foobar.com") + user, err := am.getUser(am.CA, "user_does_not_exist@foobar.com") if err != nil { t.Fatalf("Error getting user: %v", err) } @@ -115,28 +127,36 @@ func TestGetUserDoesNotAlreadyExist(t *testing.T) { } func TestGetUserAlreadyExists(t *testing.T) { + am := &ACMEManager{CA: dummyCA} testConfig := &Config{ - CA: "https://example.com/acme/directory", + Issuer: am, Storage: &FileStorage{Path: "./_testdata2_tmp"}, certCache: new(Cache), } + am.config = testConfig + testStorageDir := testConfig.Storage.(*FileStorage).Path - defer os.RemoveAll(testStorageDir) + defer func() { + err := os.RemoveAll(testStorageDir) + if err != nil { + t.Fatalf("Could not remove temporary storage directory (%s): %v", testStorageDir, err) + } + }() email := "me@foobar.com" // Set up test - user, err := testConfig.newUser(email) + user, err := am.newUser(email) if err != nil { t.Fatalf("Error creating user: %v", err) } - err = testConfig.saveUser(user) + err = am.saveUser(am.CA, user) if err != nil { t.Fatalf("Error saving user: %v", err) } // Expect to load user from disk - user2, err := testConfig.getUser(email) + user2, err := am.getUser(am.CA, email) if err != nil { t.Fatalf("Error getting user: %v", err) } @@ -153,33 +173,37 @@ func TestGetUserAlreadyExists(t *testing.T) { } func TestGetEmailFromPackageDefault(t *testing.T) { - Default.Email = "tEsT2@foo.com" + DefaultACME.Email = "tEsT2@foo.com" defer func() { - Default.Email = "" + DefaultACME.Email = "" }() + am := &ACMEManager{CA: dummyCA} testConfig := &Config{ - CA: "https://example.com/acme/directory", - Storage: &FileStorage{Path: "./_testdata_tmp"}, + Issuer: am, + Storage: &FileStorage{Path: "./_testdata2_tmp"}, certCache: new(Cache), } + am.config = testConfig - err := testConfig.getEmail(true) + err := am.getEmail(true) if err != nil { t.Fatalf("getEmail error: %v", err) } - lowerEmail := strings.ToLower(Default.Email) - if testConfig.Email != lowerEmail { - t.Errorf("Did not get correct email from memory; expected '%s' but got '%s'", lowerEmail, testConfig.Email) + lowerEmail := strings.ToLower(DefaultACME.Email) + if am.Email != lowerEmail { + t.Errorf("Did not get correct email from memory; expected '%s' but got '%s'", lowerEmail, am.Email) } } func TestGetEmailFromUserInput(t *testing.T) { + am := &ACMEManager{CA: dummyCA} testConfig := &Config{ - CA: "https://example.com/acme/directory", + Issuer: am, Storage: &FileStorage{Path: "./_testdata3_tmp"}, certCache: new(Cache), } + am.config = testConfig // let's not clutter up the output origStdout := os.Stdout @@ -192,46 +216,54 @@ func TestGetEmailFromUserInput(t *testing.T) { email := "test3@foo.com" stdin = bytes.NewBufferString(email + "\n") - err := testConfig.getEmail(true) + err := am.getEmail(true) if err != nil { t.Fatalf("getEmail error: %v", err) } - if testConfig.Email != email { - t.Errorf("Did not get correct email from user input prompt; expected '%s' but got '%s'", email, testConfig.Email) + if am.Email != email { + t.Errorf("Did not get correct email from user input prompt; expected '%s' but got '%s'", email, am.Email) } - if !testConfig.Agreed { + if !am.Agreed { t.Error("Expect Config.Agreed to be true, but got false") } } func TestGetEmailFromRecent(t *testing.T) { + am := &ACMEManager{CA: dummyCA} testConfig := &Config{ - CA: "https://example.com/acme/directory", + Issuer: am, Storage: &FileStorage{Path: "./_testdata4_tmp"}, certCache: new(Cache), } + am.config = testConfig + testStorageDir := testConfig.Storage.(*FileStorage).Path - defer os.RemoveAll(testStorageDir) + defer func() { + err := os.RemoveAll(testStorageDir) + if err != nil { + t.Fatalf("Could not remove temporary storage directory (%s): %v", testStorageDir, err) + } + }() - Default.Email = "" + DefaultACME.Email = "" for i, eml := range []string{ "test4-1@foo.com", "test4-2@foo.com", "TEST4-3@foo.com", // test case insensitivity } { - u, err := testConfig.newUser(eml) + u, err := am.newUser(eml) if err != nil { t.Fatalf("Error creating user %d: %v", i, err) } - err = testConfig.saveUser(u) + err = am.saveUser(am.CA, u) if err != nil { t.Fatalf("Error saving user %d: %v", i, err) } // Change modified time so they're all different and the test becomes more deterministic fs := testConfig.Storage.(*FileStorage) - userFolder := filepath.Join(fs.Path, StorageKeys.UserPrefix(testConfig.CA, eml)) + userFolder := filepath.Join(fs.Path, am.storageKeyUserPrefix(am.CA, eml)) f, err := os.Stat(userFolder) if err != nil { t.Fatalf("Could not access user folder for '%s': %v", eml, err) @@ -241,11 +273,11 @@ func TestGetEmailFromRecent(t *testing.T) { t.Fatalf("Could not change user folder mod time for '%s': %v", eml, err) } } - err := testConfig.getEmail(true) + err := am.getEmail(true) if err != nil { t.Fatalf("getEmail error: %v", err) } - if testConfig.Email != "test4-3@foo.com" { - t.Errorf("Did not get correct email from storage; expected '%s' but got '%s'", "test4-3@foo.com", testConfig.Email) + if am.Email != "test4-3@foo.com" { + t.Errorf("Did not get correct email from storage; expected '%s' but got '%s'", "test4-3@foo.com", am.Email) } }