From d18cf12f148f734008cd9451fdcb7c1a16d823fb Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 2 Nov 2015 19:27:23 -0700 Subject: [PATCH] letsencrypt: Fixed renewals By chaining in a middleware handler and using newly exposed hooks from the acme package, we're able to proxy ACME requests on port 443 to the ACME client listening on a different port. --- caddy/letsencrypt/handler.go | 67 ++++++++++++++++++++++++++++++++ caddy/letsencrypt/letsencrypt.go | 34 +++++++++++++--- caddy/letsencrypt/maintain.go | 47 ++++++++++++++++------ 3 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 caddy/letsencrypt/handler.go diff --git a/caddy/letsencrypt/handler.go b/caddy/letsencrypt/handler.go new file mode 100644 index 00000000..c97d47df --- /dev/null +++ b/caddy/letsencrypt/handler.go @@ -0,0 +1,67 @@ +package letsencrypt + +import ( + "crypto/tls" + "net/http" + "net/http/httputil" + "net/url" + "sync" + "sync/atomic" + + "github.com/mholt/caddy/middleware" +) + +// Handler is a Caddy middleware that can proxy ACME requests +// to the real ACME endpoint. This is necessary to renew certificates +// while the server is running. Obviously, a site served on port +// 443 (HTTPS) binds to that port, so another listener created by +// our acme client can't bind successfully and solve the challenge. +// Thus, we chain this handler in so that it can, when activated, +// proxy ACME requests to an ACME client listening on an alternate +// port. +type Handler struct { + sync.Mutex // protects the ChallengePath property + Next middleware.Handler + ChallengeActive int32 // use sync/atomic for speed to set/get this flag + ChallengePath string // the exact request path to match before proxying +} + +// ServeHTTP is basically a no-op unless an ACME challenge is active on this host +// and the request path matches the expected path exactly. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) { + // Only if challenge is active + if atomic.LoadInt32(&h.ChallengeActive) == 1 { + h.Lock() + path := h.ChallengePath + h.Unlock() + + // Request path must be correct; if so, proxy to ACME client + if r.URL.Path == path { + upstream, err := url.Parse("https://" + r.Host + ":" + alternatePort) + if err != nil { + return http.StatusInternalServerError, err + } + proxy := httputil.NewSingleHostReverseProxy(upstream) + proxy.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // client uses self-signed cert + } + proxy.ServeHTTP(w, r) + return 0, nil + } + } + + return h.Next.ServeHTTP(w, r) +} + +// ChallengeOn enables h to proxy ACME requests. +func (h *Handler) ChallengeOn(challengePath string) { + h.Lock() + h.ChallengePath = challengePath + h.Unlock() + atomic.StoreInt32(&h.ChallengeActive, 1) +} + +// ChallengeOff disables ACME proxying from this h. +func (h *Handler) ChallengeOff(success bool) { + atomic.StoreInt32(&h.ChallengeActive, 0) +} diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index 7848e627..1b73fb6e 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -82,7 +82,7 @@ func Activate(configs []server.Config) ([]server.Config, error) { return configs, errors.New("error creating client: " + err.Error()) } - // client is ready, so let's get free, trusted SSL certificates! yeah! + // client is ready, so let's get free, trusted SSL certificates! Obtain: certificates, failures := obtainCertificates(client, serverConfigs) if len(failures) > 0 { @@ -128,7 +128,7 @@ func Activate(configs []server.Config) ([]server.Config, error) { } // renew all certificates that need renewal - renewCertificates(configs) + renewCertificates(configs, false) // keep certificates renewed and OCSP stapling updated go maintainAssets(configs, stopChan) @@ -167,8 +167,8 @@ func configQualifies(cfg server.Config, allConfigs []server.Config) bool { cfg.Host != "" && cfg.Host != "0.0.0.0" && cfg.Host != "::1" && - !strings.HasPrefix(cfg.Host, "127.") && - // TODO: Also exclude 10.* and 192.168.* addresses? + !strings.HasPrefix(cfg.Host, "127.") && // to use a boulder on your own machine, add fake domain to hosts file + // not excluding 10.* and 192.168.* hosts for possibility of running internal Boulder instance // make sure an HTTPS version of this config doesn't exist in the list already !hostHasOtherScheme(cfg.Host, "https", allConfigs) @@ -215,6 +215,14 @@ func existingCertAndKey(host string) bool { // disk (if already exists) or created new and registered via ACME // and saved to the file system for next time. func newClient(leEmail string) (*acme.Client, error) { + return newClientPort(leEmail, exposePort) +} + +// newClientPort does the same thing as newClient, except it creates a +// new client with a custom port used for ACME transactions instead of +// the default port. This is important if the default port is already in +// use or is not exposed to the public, etc. +func newClientPort(leEmail, port string) (*acme.Client, error) { // Look up or create the LE user account leUser, err := getUser(leEmail) if err != nil { @@ -222,7 +230,7 @@ func newClient(leEmail string) (*acme.Client, error) { } // The client facilitates our communication with the CA server. - client, err := acme.NewClient(CAUrl, &leUser, rsaKeySizeToUse, exposePort) + client, err := acme.NewClient(CAUrl, &leUser, rsaKeySizeToUse, port) if err != nil { return nil, err } @@ -325,6 +333,17 @@ func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Conf cfg.Port = "https" } + // Chain in ACME middleware proxy if we use up the SSL port + if cfg.Port == "https" || cfg.Port == "443" { + handler := new(Handler) + mid := func(next middleware.Handler) middleware.Handler { + handler.Next = next + return handler + } + cfg.Middleware["/"] = append(cfg.Middleware["/"], mid) + acmeHandlers[cfg.Host] = handler + } + // Set up http->https redirect as long as there isn't already // a http counterpart in the configs if !hostHasOtherScheme(cfg.Host, "http", allConfigs) { @@ -440,6 +459,11 @@ const ( // then port 443 must be forwarded to exposePort. exposePort = "443" + // If port 443 is in use by a Caddy server instance, then this is + // port on which the acme client will solve challenges. (Whatever is + // listening on port 443 must proxy ACME requests to this port.) + alternatePort = "5033" + // How often to check certificates for renewal. renewInterval = 24 * time.Hour diff --git a/caddy/letsencrypt/maintain.go b/caddy/letsencrypt/maintain.go index 7642a238..62c71428 100644 --- a/caddy/letsencrypt/maintain.go +++ b/caddy/letsencrypt/maintain.go @@ -33,15 +33,17 @@ func maintainAssets(configs []server.Config, stopChan chan struct{}) { for { select { case <-renewalTicker.C: - if n, errs := renewCertificates(configs); len(errs) > 0 { + n, errs := renewCertificates(configs, true) + if len(errs) > 0 { for _, err := range errs { log.Printf("[ERROR] cert renewal: %v\n", err) } - if n > 0 && OnChange != nil { - err := OnChange() - if err != nil { - log.Printf("[ERROR] onchange after cert renewal: %v\n", err) - } + } + // even if there was an error, some renewals may have succeeded + if n > 0 && OnChange != nil { + err := OnChange() + if err != nil { + log.Printf("[ERROR] onchange after cert renewal: %v\n", err) } } case <-ocspTicker.C: @@ -69,11 +71,20 @@ func maintainAssets(configs []server.Config, stopChan chan struct{}) { // through this function; all changes happen directly on disk. // It returns the number of certificates renewed and any errors // that occurred. It only performs a renewal if necessary. -func renewCertificates(configs []server.Config) (int, []error) { +// If useCustomPort is true, a custom port will be used, and +// whatever is listening at 443 better proxy ACME requests to it. +// Otherwise, the acme package will create its own listener on 443. +func renewCertificates(configs []server.Config, useCustomPort bool) (int, []error) { log.Print("[INFO] Processing certificate renewals...") var errs []error var n int + defer func() { + // reset these so as to not interfere with other challenges + acme.OnSimpleHTTPStart = nil + acme.OnSimpleHTTPEnd = nil + }() + for _, cfg := range configs { // Host must be TLS-enabled and have existing assets managed by LE if !cfg.TLS.Enabled || !existingCertAndKey(cfg.Host) { @@ -100,7 +111,12 @@ func renewCertificates(configs []server.Config) (int, []error) { // Renew with two weeks or less remaining. if daysLeft <= 14 { log.Printf("[INFO] There are %d days left on the certificate of %s. Trying to renew now.", daysLeft, cfg.Host) - client, err := newClient("") // email not used for renewal + var client *acme.Client + if useCustomPort { + client, err = newClientPort("", alternatePort) // email not used for renewal + } else { + client, err = newClient("") + } if err != nil { errs = append(errs, err) continue @@ -124,6 +140,10 @@ func renewCertificates(configs []server.Config) (int, []error) { certMeta.Certificate = certBytes certMeta.PrivateKey = privBytes + // Tell the handler to accept and proxy acme request in order to solve challenge + acme.OnSimpleHTTPStart = acmeHandlers[cfg.Host].ChallengeOn + acme.OnSimpleHTTPEnd = acmeHandlers[cfg.Host].ChallengeOff + // Renew certificate. // TODO: revokeOld should be an option in the caddyfile // TODO: bundle should be an option in the caddyfile as well :) @@ -148,11 +168,16 @@ func renewCertificates(configs []server.Config) (int, []error) { saveCertsAndKeys([]acme.CertificateResource{newCertMeta}) n++ - } else if daysLeft <= 14 { - // Warn on 14 days remaining - log.Printf("[WARN] There are %d days left on the certificate for %s. Will renew when 7 days remain.\n", daysLeft, cfg.Host) + } else if daysLeft <= 30 { + // Warn on 30 days remaining. TODO: Just do this once... + log.Printf("[WARN] There are %d days left on the certificate for %s. Will renew when 14 days remain.\n", daysLeft, cfg.Host) } } return n, errs } + +// acmeHandlers is a map of host to ACME handler. These +// are used to proxy ACME requests to the ACME client +// when port 443 is in use. +var acmeHandlers = make(map[string]*Handler)