Commit 55601d3e authored by Matthew Holt's avatar Matthew Holt

letsencrypt: Fix OCSP stapling and restarts with new LE-capable hosts

Before, Caddy couldn't support graceful (zero-downtime) restarts when the reloaded Caddyfile had a host in it that was elligible for a LE certificate because the port was already in use. This commit makes it possible to do zero-downtime reloads and issue certificates for new hosts that need it. Supports only http-01 challenge at this time.

OCSP stapling is improved in that it updates before the expiration time when the validity window has shifted forward. See 30c94908. Before it only used to update when the status changed.

This commit also sets the user agent for Let's Encrypt requests with a string containing "Caddy".
parent 829a0f34
...@@ -191,6 +191,7 @@ func startServers(groupings bindingGroup) error { ...@@ -191,6 +191,7 @@ func startServers(groupings bindingGroup) error {
return err return err
} }
s.HTTP2 = HTTP2 // TODO: This setting is temporary s.HTTP2 = HTTP2 // TODO: This setting is temporary
s.ReqCallback = letsencrypt.RequestCallback // ensures we can solve ACME challenges while running
var ln server.ListenerFile var ln server.ListenerFile
if IsRestart() { if IsRestart() {
......
...@@ -40,12 +40,12 @@ func TestSaveAndLoadRSAPrivateKey(t *testing.T) { ...@@ -40,12 +40,12 @@ func TestSaveAndLoadRSAPrivateKey(t *testing.T) {
} }
} }
// rsaPrivateKeyBytes returns the bytes of DER-encoded key.
func rsaPrivateKeyBytes(key *rsa.PrivateKey) []byte {
return x509.MarshalPKCS1PrivateKey(key)
}
// rsaPrivateKeysSame compares the bytes of a and b and returns true if they are the same. // rsaPrivateKeysSame compares the bytes of a and b and returns true if they are the same.
func rsaPrivateKeysSame(a, b *rsa.PrivateKey) bool { func rsaPrivateKeysSame(a, b *rsa.PrivateKey) bool {
return bytes.Equal(rsaPrivateKeyBytes(a), rsaPrivateKeyBytes(b)) return bytes.Equal(rsaPrivateKeyBytes(a), rsaPrivateKeyBytes(b))
} }
// rsaPrivateKeyBytes returns the bytes of DER-encoded key.
func rsaPrivateKeyBytes(key *rsa.PrivateKey) []byte {
return x509.MarshalPKCS1PrivateKey(key)
}
...@@ -2,30 +2,21 @@ package letsencrypt ...@@ -2,30 +2,21 @@ package letsencrypt
import ( import (
"crypto/tls" "crypto/tls"
"log"
"net" "net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"strings" "strings"
"github.com/mholt/caddy/middleware"
) )
const challengeBasePath = "/.well-known/acme-challenge" const challengeBasePath = "/.well-known/acme-challenge"
// Handler is a Caddy middleware that can proxy ACME challenge // RequestCallback proxies challenge requests to ACME client if the
// requests to the real ACME client endpoint. This is necessary // request path starts with challengeBasePath. It returns true if it
// to renew certificates while the server is running. // handled the request and no more needs to be done; it returns false
type Handler struct { // if this call was a no-op and the request still needs handling.
Next middleware.Handler func RequestCallback(w http.ResponseWriter, r *http.Request) bool {
//ChallengeActive int32 // (TODO) use sync/atomic to set/get this flag safely and efficiently
}
// 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) {
// Proxy challenge requests to ACME client
// TODO: Only do this if a challenge is active?
if strings.HasPrefix(r.URL.Path, challengeBasePath) { if strings.HasPrefix(r.URL.Path, challengeBasePath) {
scheme := "http" scheme := "http"
if r.TLS != nil { if r.TLS != nil {
...@@ -37,9 +28,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) ...@@ -37,9 +28,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error)
hostname = r.URL.Host hostname = r.URL.Host
} }
upstream, err := url.Parse(scheme + "://" + hostname + ":" + alternatePort) upstream, err := url.Parse(scheme + "://" + hostname + ":" + AlternatePort)
if err != nil { if err != nil {
return http.StatusInternalServerError, err w.WriteHeader(http.StatusInternalServerError)
log.Printf("[ERROR] letsencrypt handler: %v", err)
return true
} }
proxy := httputil.NewSingleHostReverseProxy(upstream) proxy := httputil.NewSingleHostReverseProxy(upstream)
...@@ -48,8 +41,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) ...@@ -48,8 +41,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error)
} }
proxy.ServeHTTP(w, r) proxy.ServeHTTP(w, r)
return 0, nil return true
} }
return h.Next.ServeHTTP(w, r) return false
} }
This diff is collapsed.
...@@ -23,9 +23,11 @@ func TestHostQualifies(t *testing.T) { ...@@ -23,9 +23,11 @@ func TestHostQualifies(t *testing.T) {
{"", false}, {"", false},
{" ", false}, {" ", false},
{"0.0.0.0", false}, {"0.0.0.0", false},
{"192.168.1.3", true}, {"192.168.1.3", false},
{"10.0.2.1", true}, {"10.0.2.1", false},
{"169.112.53.4", false},
{"foobar.com", true}, {"foobar.com", true},
{"sub.foobar.com", true},
} { } {
if HostQualifies(test.host) && !test.expect { if HostQualifies(test.host) && !test.expect {
t.Errorf("Test %d: Expected '%s' to NOT qualify, but it did", i, test.host) t.Errorf("Test %d: Expected '%s' to NOT qualify, but it did", i, test.host)
...@@ -39,14 +41,14 @@ func TestHostQualifies(t *testing.T) { ...@@ -39,14 +41,14 @@ func TestHostQualifies(t *testing.T) {
func TestRedirPlaintextHost(t *testing.T) { func TestRedirPlaintextHost(t *testing.T) {
cfg := redirPlaintextHost(server.Config{ cfg := redirPlaintextHost(server.Config{
Host: "example.com", Host: "example.com",
Port: "http", Port: "80",
}) })
// Check host and port // Check host and port
if actual, expected := cfg.Host, "example.com"; actual != expected { if actual, expected := cfg.Host, "example.com"; actual != expected {
t.Errorf("Expected redir config to have host %s but got %s", expected, actual) t.Errorf("Expected redir config to have host %s but got %s", expected, actual)
} }
if actual, expected := cfg.Port, "http"; actual != expected { if actual, expected := cfg.Port, "80"; actual != expected {
t.Errorf("Expected redir config to have port '%s' but got '%s'", expected, actual) t.Errorf("Expected redir config to have port '%s' but got '%s'", expected, actual)
} }
......
...@@ -27,8 +27,8 @@ var OnChange func() error ...@@ -27,8 +27,8 @@ var OnChange func() error
// which you'll close when maintenance should stop, to allow this // which you'll close when maintenance should stop, to allow this
// goroutine to clean up after itself and unblock. // goroutine to clean up after itself and unblock.
func maintainAssets(configs []server.Config, stopChan chan struct{}) { func maintainAssets(configs []server.Config, stopChan chan struct{}) {
renewalTicker := time.NewTicker(renewInterval) renewalTicker := time.NewTicker(RenewInterval)
ocspTicker := time.NewTicker(ocspInterval) ocspTicker := time.NewTicker(OCSPInterval)
for { for {
select { select {
...@@ -47,17 +47,27 @@ func maintainAssets(configs []server.Config, stopChan chan struct{}) { ...@@ -47,17 +47,27 @@ func maintainAssets(configs []server.Config, stopChan chan struct{}) {
} }
} }
case <-ocspTicker.C: case <-ocspTicker.C:
for bundle, oldStatus := range ocspStatus { for bundle, oldResp := range ocspCache {
_, newStatus, err := acme.GetOCSPForCert(*bundle) // start checking OCSP staple about halfway through validity period for good measure
if err == nil && newStatus != oldStatus && OnChange != nil { refreshTime := oldResp.ThisUpdate.Add(oldResp.NextUpdate.Sub(oldResp.ThisUpdate) / 10)
log.Printf("[INFO] OCSP status changed from %v to %v", oldStatus, newStatus) if time.Now().After(refreshTime) {
_, newResp, err := acme.GetOCSPForCert(*bundle)
if err != nil {
log.Printf("[ERROR] Checking OCSP for bundle: %v", err)
continue
}
if newResp.NextUpdate != oldResp.NextUpdate {
if OnChange != nil {
log.Printf("[INFO] Updating OCSP stapling to extend validity period to %v", newResp.NextUpdate)
err := OnChange() err := OnChange()
if err != nil { if err != nil {
log.Printf("[ERROR] OnChange after OCSP update: %v", err) log.Printf("[ERROR] OnChange after OCSP trigger: %v", err)
} }
break break
} }
} }
}
}
case <-stopChan: case <-stopChan:
renewalTicker.Stop() renewalTicker.Stop()
ocspTicker.Stop() ocspTicker.Stop()
...@@ -107,7 +117,7 @@ func renewCertificates(configs []server.Config, useCustomPort bool) (int, []erro ...@@ -107,7 +117,7 @@ func renewCertificates(configs []server.Config, useCustomPort bool) (int, []erro
log.Printf("[INFO] Certificate for %s has %d days remaining; attempting renewal", cfg.Host, daysLeft) log.Printf("[INFO] Certificate for %s has %d days remaining; attempting renewal", cfg.Host, daysLeft)
var client *acme.Client var client *acme.Client
if useCustomPort { if useCustomPort {
client, err = newClientPort("", alternatePort) // email not used for renewal client, err = newClientPort("", AlternatePort) // email not used for renewal
} else { } else {
client, err = newClient("") client, err = newClient("")
} }
...@@ -134,7 +144,7 @@ func renewCertificates(configs []server.Config, useCustomPort bool) (int, []erro ...@@ -134,7 +144,7 @@ func renewCertificates(configs []server.Config, useCustomPort bool) (int, []erro
// Renew certificate // Renew certificate
Renew: Renew:
newCertMeta, err := client.RenewCertificate(certMeta, true, true) newCertMeta, err := client.RenewCertificate(certMeta, true)
if err != nil { if err != nil {
if _, ok := err.(acme.TOSError); ok { if _, ok := err.(acme.TOSError); ok {
err := client.AgreeToTOS() err := client.AgreeToTOS()
...@@ -145,24 +155,20 @@ func renewCertificates(configs []server.Config, useCustomPort bool) (int, []erro ...@@ -145,24 +155,20 @@ func renewCertificates(configs []server.Config, useCustomPort bool) (int, []erro
} }
time.Sleep(10 * time.Second) time.Sleep(10 * time.Second)
newCertMeta, err = client.RenewCertificate(certMeta, true, true) newCertMeta, err = client.RenewCertificate(certMeta, true)
if err != nil { if err != nil {
errs = append(errs, err) errs = append(errs, err)
continue continue
} }
} }
saveCertsAndKeys([]acme.CertificateResource{newCertMeta}) saveCertResource(newCertMeta)
n++ n++
} else if daysLeft <= 30 { } else if daysLeft <= 21 {
// Warn on 30 days remaining. TODO: Just do this once... // Warn on 21 days remaining. TODO: Just do this once...
log.Printf("[WARNING] Certificate for %s has %d days remaining; will automatically renew when 14 days remain\n", cfg.Host, daysLeft) log.Printf("[WARNING] Certificate for %s has %d days remaining; will automatically renew when 14 days remain\n", cfg.Host, daysLeft)
} }
} }
return n, errs return n, errs
} }
// acmeHandlers is a map of host to ACME handler. These
// are used to proxy ACME requests to the ACME client.
var acmeHandlers = make(map[string]*Handler)
...@@ -3,11 +3,17 @@ ...@@ -3,11 +3,17 @@
package caddy package caddy
import ( import (
"bytes"
"encoding/gob" "encoding/gob"
"errors"
"io/ioutil" "io/ioutil"
"log" "log"
"os" "os"
"os/exec" "os/exec"
"path"
"github.com/mholt/caddy/caddy/letsencrypt"
"github.com/mholt/caddy/server"
) )
func init() { func init() {
...@@ -33,6 +39,12 @@ func Restart(newCaddyfile Input) error { ...@@ -33,6 +39,12 @@ func Restart(newCaddyfile Input) error {
caddyfileMu.Unlock() caddyfileMu.Unlock()
} }
// Get certificates for any new hosts in the new Caddyfile without causing downtime
err := getCertsForNewCaddyfile(newCaddyfile)
if err != nil {
return errors.New("TLS preload: " + err.Error())
}
if len(os.Args) == 0 { // this should never happen, but... if len(os.Args) == 0 { // this should never happen, but...
os.Args = []string{""} os.Args = []string{""}
} }
...@@ -61,7 +73,7 @@ func Restart(newCaddyfile Input) error { ...@@ -61,7 +73,7 @@ func Restart(newCaddyfile Input) error {
// Pass along relevant file descriptors to child process; ordering // Pass along relevant file descriptors to child process; ordering
// is very important since we rely on these being in certain positions. // is very important since we rely on these being in certain positions.
extraFiles := []*os.File{sigwpipe} extraFiles := []*os.File{sigwpipe} // fd 3
// Add file descriptors of all the sockets // Add file descriptors of all the sockets
serversMu.Lock() serversMu.Lock()
...@@ -110,3 +122,45 @@ func Restart(newCaddyfile Input) error { ...@@ -110,3 +122,45 @@ func Restart(newCaddyfile Input) error {
// Looks like child is successful; we can exit gracefully. // Looks like child is successful; we can exit gracefully.
return Stop() return Stop()
} }
func getCertsForNewCaddyfile(newCaddyfile Input) error {
// parse the new caddyfile only up to (and including) TLS
// so we can know what we need to get certs for.
configs, _, _, err := loadConfigsUpToIncludingTLS(path.Base(newCaddyfile.Path()), bytes.NewReader(newCaddyfile.Body()))
if err != nil {
return errors.New("loading Caddyfile: " + err.Error())
}
// TODO: Yuck, this is hacky. port 443 not set until letsencrypt is activated, so we change it here.
for i := range configs {
if configs[i].Port == "" && letsencrypt.ConfigQualifies(configs, i) {
configs[i].Port = "443"
}
}
// only get certs for configs that bind to an address we're already listening on
groupings, err := arrangeBindings(configs)
if err != nil {
return errors.New("arranging bindings: " + err.Error())
}
var configsToSetup []server.Config
serversMu.Lock()
GroupLoop:
for _, group := range groupings {
for _, server := range servers {
if server.Addr == group.BindAddr.String() {
configsToSetup = append(configsToSetup, group.Configs...)
continue GroupLoop
}
}
}
serversMu.Unlock()
// obtain certs for eligible configs; letsencrypt pkg will filter out the rest.
configs, err = letsencrypt.ObtainCertsAndConfigure(configsToSetup, letsencrypt.AlternatePort)
if err != nil {
return errors.New("obtaining certs: " + err.Error())
}
return nil
}
...@@ -14,6 +14,7 @@ import ( ...@@ -14,6 +14,7 @@ import (
"github.com/mholt/caddy/caddy" "github.com/mholt/caddy/caddy"
"github.com/mholt/caddy/caddy/letsencrypt" "github.com/mholt/caddy/caddy/letsencrypt"
"github.com/xenolf/lego/acme"
) )
var ( var (
...@@ -53,6 +54,7 @@ func main() { ...@@ -53,6 +54,7 @@ func main() {
caddy.AppName = appName caddy.AppName = appName
caddy.AppVersion = appVersion caddy.AppVersion = appVersion
acme.UserAgent = appName + "/" + appVersion
// set up process log before anything bad happens // set up process log before anything bad happens
switch logfile { switch logfile {
......
...@@ -33,6 +33,7 @@ type Server struct { ...@@ -33,6 +33,7 @@ type Server struct {
httpWg sync.WaitGroup // used to wait on outstanding connections httpWg sync.WaitGroup // used to wait on outstanding connections
startChan chan struct{} // used to block until server is finished starting startChan chan struct{} // used to block until server is finished starting
connTimeout time.Duration // the maximum duration of a graceful shutdown connTimeout time.Duration // the maximum duration of a graceful shutdown
ReqCallback OptionalCallback // if non-nil, is executed at the beginning of every request
} }
// ListenerFile represents a listener. // ListenerFile represents a listener.
...@@ -41,6 +42,11 @@ type ListenerFile interface { ...@@ -41,6 +42,11 @@ type ListenerFile interface {
File() (*os.File, error) File() (*os.File, error)
} }
// OptionalCallback is a function that may or may not handle a request.
// It returns whether or not it handled the request. If it handled the
// request, it is presumed that no further request handling should occur.
type OptionalCallback func(http.ResponseWriter, *http.Request) bool
// New creates a new Server which will bind to addr and serve // New creates a new Server which will bind to addr and serve
// the sites/hosts configured in configs. Its listener will // the sites/hosts configured in configs. Its listener will
// gracefully close when the server is stopped which will take // gracefully close when the server is stopped which will take
...@@ -309,6 +315,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { ...@@ -309,6 +315,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
}() }()
w.Header().Set("Server", "Caddy")
// Execute the optional request callback if it exists
if s.ReqCallback != nil && s.ReqCallback(w, r) {
return
}
host, _, err := net.SplitHostPort(r.Host) host, _, err := net.SplitHostPort(r.Host)
if err != nil { if err != nil {
host = r.Host // oh well host = r.Host // oh well
...@@ -324,8 +337,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { ...@@ -324,8 +337,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
if vh, ok := s.vhosts[host]; ok { if vh, ok := s.vhosts[host]; ok {
w.Header().Set("Server", "Caddy")
status, _ := vh.stack.ServeHTTP(w, r) status, _ := vh.stack.ServeHTTP(w, r)
// Fallback error response in case error handling wasn't chained in // Fallback error response in case error handling wasn't chained in
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment