diff --git a/caddy/caddy.go b/caddy/caddy.go index 600abe668..5d8ceddd8 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -28,7 +28,7 @@ import ( "sync" "time" - "github.com/mholt/caddy/caddy/letsencrypt" + "github.com/mholt/caddy/caddy/https" "github.com/mholt/caddy/server" ) @@ -44,7 +44,7 @@ var ( Quiet bool // HTTP2 indicates whether HTTP2 is enabled or not. - HTTP2 bool // TODO: temporary flag until http2 is standard + HTTP2 bool // PidFile is the path to the pidfile to create. PidFile string @@ -191,9 +191,13 @@ func startServers(groupings bindingGroup) error { if err != nil { return err } - s.HTTP2 = HTTP2 // TODO: This setting is temporary - s.ReqCallback = letsencrypt.RequestCallback // ensures we can solve ACME challenges while running - s.SNICallback = letsencrypt.GetCertificateDuringHandshake // TLS on demand -- awesome! + s.HTTP2 = HTTP2 + s.ReqCallback = https.RequestCallback // ensures we can solve ACME challenges while running + if s.OnDemandTLS { + s.TLSConfig.GetCertificate = https.GetOrObtainCertificate // TLS on demand -- awesome! + } else { + s.TLSConfig.GetCertificate = https.GetCertificate + } var ln server.ListenerFile if IsRestart() { @@ -278,7 +282,7 @@ func startServers(groupings bindingGroup) error { // It does NOT execute shutdown callbacks that may have been // configured by middleware (they must be executed separately). func Stop() error { - letsencrypt.Deactivate() + https.Deactivate() serversMu.Lock() for _, s := range servers { diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index ae84b31df..24a5d3026 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -4,10 +4,21 @@ import ( "net/http" "testing" "time" + + "github.com/mholt/caddy/caddy/https" + "github.com/xenolf/lego/acme" ) func TestCaddyStartStop(t *testing.T) { - caddyfile := "localhost:1984\ntls off" + // Use fake ACME clients for testing + https.NewACMEClient = func(email string, allowPrompts bool) (*https.ACMEClient, error) { + return &https.ACMEClient{ + Client: new(acme.Client), + AllowPrompts: allowPrompts, + }, nil + } + + caddyfile := "localhost:1984" for i := 0; i < 2; i++ { err := Start(CaddyfileInput{Contents: []byte(caddyfile)}) diff --git a/caddy/config.go b/caddy/config.go index 3ff63b481..15420e315 100644 --- a/caddy/config.go +++ b/caddy/config.go @@ -8,7 +8,7 @@ import ( "net" "sync" - "github.com/mholt/caddy/caddy/letsencrypt" + "github.com/mholt/caddy/caddy/https" "github.com/mholt/caddy/caddy/parse" "github.com/mholt/caddy/caddy/setup" "github.com/mholt/caddy/middleware" @@ -128,7 +128,7 @@ func loadConfigs(filename string, input io.Reader) ([]server.Config, error) { if !IsRestart() && !Quiet { fmt.Print("Activating privacy features...") } - configs, err = letsencrypt.Activate(configs) + configs, err = https.Activate(configs) if err != nil { return nil, err } else if !IsRestart() && !Quiet { @@ -318,7 +318,7 @@ func validDirective(d string) bool { // root. func DefaultInput() CaddyfileInput { port := Port - if letsencrypt.HostQualifies(Host) && port == DefaultPort { + if https.HostQualifies(Host) && port == DefaultPort { port = "443" } return CaddyfileInput{ diff --git a/caddy/directives.go b/caddy/directives.go index 39b54b7d6..d98ab5118 100644 --- a/caddy/directives.go +++ b/caddy/directives.go @@ -1,6 +1,7 @@ package caddy import ( + "github.com/mholt/caddy/caddy/https" "github.com/mholt/caddy/caddy/parse" "github.com/mholt/caddy/caddy/setup" "github.com/mholt/caddy/middleware" @@ -43,7 +44,7 @@ var directiveOrder = []directive{ // Essential directives that initialize vital configuration settings {"root", setup.Root}, {"bind", setup.BindHost}, - {"tls", setup.TLS}, // letsencrypt is set up just after tls + {"tls", https.Setup}, // Other directives that don't create HTTP handlers {"startup", setup.Startup}, diff --git a/caddy/helpers.go b/caddy/helpers.go index f864b54b4..0165573ac 100644 --- a/caddy/helpers.go +++ b/caddy/helpers.go @@ -11,14 +11,8 @@ import ( "strconv" "strings" "sync" - - "github.com/mholt/caddy/caddy/letsencrypt" ) -func init() { - letsencrypt.OnChange = func() error { return Restart(nil) } -} - // isLocalhost returns true if host looks explicitly like a localhost address. func isLocalhost(host string) bool { return host == "localhost" || host == "::1" || strings.HasPrefix(host, "127.") diff --git a/caddy/https/certificates.go b/caddy/https/certificates.go new file mode 100644 index 000000000..72a9ff1c7 --- /dev/null +++ b/caddy/https/certificates.go @@ -0,0 +1,232 @@ +package https + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "io/ioutil" + "log" + "strings" + "sync" + "time" + + "github.com/xenolf/lego/acme" + "golang.org/x/crypto/ocsp" +) + +// certCache stores certificates in memory, +// keying certificates by name. +var certCache = make(map[string]Certificate) +var certCacheMu sync.RWMutex + +// Certificate is a tls.Certificate with associated metadata tacked on. +// Even if the metadata can be obtained by parsing the certificate, +// we can be more efficient by extracting the metadata once so it's +// just there, ready to use. +type Certificate struct { + *tls.Certificate + + // Names is the list of names this certificate is written for. + // The first is the CommonName (if any), the rest are SAN. + Names []string + + // NotAfter is when the certificate expires. + NotAfter time.Time + + // Managed certificates are certificates that Caddy is managing, + // as opposed to the user specifying a certificate and key file + // or directory and managing the certificate resources themselves. + Managed bool + + // OnDemand certificates are obtained or loaded on-demand during TLS + // handshakes (as opposed to preloaded certificates, which are loaded + // at startup). If OnDemand is true, Managed must necessarily be true. + // OnDemand certificates are maintained in the background just like + // preloaded ones, however, if an OnDemand certificate fails to renew, + // it is removed from the in-memory cache. + OnDemand bool + + // OCSP contains the certificate's parsed OCSP response. + OCSP *ocsp.Response +} + +// getCertificate gets a certificate from the in-memory cache that +// matches name (a certificate name). Note that if name does not have +// an exact match, it will be checked against names of the form +// '*.example.com' (wildcard certificates) according to RFC 6125. +// +// If cert was found by matching name, matched will be returned true. +// If no match is found, the default certificate will be returned and +// matched will be returned as false. (The default certificate is the +// first one that entered the cache.) If the cache is empty (or there +// is no default certificate for some reason), matched will still be +// false, but cert.Certificate will be nil. +// +// The logic in this function is adapted from the Go standard library, +// which is by the Go Authors. +// +// This function is safe for concurrent use. +func getCertificate(name string) (cert Certificate, matched bool) { + // Not going to trim trailing dots here since RFC 3546 says, + // "The hostname is represented ... without a trailing dot." + // Just normalize to lowercase. + name = strings.ToLower(name) + + certCacheMu.RLock() + defer certCacheMu.RUnlock() + + // exact match? great, let's use it + if cert, ok := certCache[name]; ok { + return cert, true + } + + // try replacing labels in the name with wildcards until we get a match + labels := strings.Split(name, ".") + for i := range labels { + labels[i] = "*" + candidate := strings.Join(labels, ".") + if cert, ok := certCache[candidate]; ok { + return cert, true + } + } + + // if nothing matches, return the default certificate + cert = certCache[""] + return cert, false +} + +// cacheManagedCertificate loads the certificate for domain into the +// cache, flagging it as Managed and, if onDemand is true, as OnDemand +// (meaning that it was obtained or loaded during a TLS handshake). +// +// This function is safe for concurrent use. +func cacheManagedCertificate(domain string, onDemand bool) (Certificate, error) { + cert, err := makeCertificateFromDisk(storage.SiteCertFile(domain), storage.SiteKeyFile(domain)) + if err != nil { + return cert, err + } + cert.Managed = true + cert.OnDemand = onDemand + cacheCertificate(cert) + return cert, nil +} + +// cacheUnmanagedCertificatePEMFile loads a certificate for host using certFile +// and keyFile, which must be in PEM format. It stores the certificate in +// memory. The Managed and OnDemand flags of the certificate will be set to +// false. +// +// This function is safe for concurrent use. +func cacheUnmanagedCertificatePEMFile(certFile, keyFile string) error { + cert, err := makeCertificateFromDisk(certFile, keyFile) + if err != nil { + return err + } + cacheCertificate(cert) + return nil +} + +// cacheUnmanagedCertificatePEMBytes makes a certificate out of the PEM bytes +// of the certificate and key, then caches it in memory. +// +// This function is safe for concurrent use. +func cacheUnmanagedCertificatePEMBytes(certBytes, keyBytes []byte) error { + cert, err := makeCertificate(certBytes, keyBytes) + if err != nil { + return err + } + cacheCertificate(cert) + return nil +} + +// makeCertificateFromDisk makes a Certificate by loading the +// certificate and key files. It fills out all the fields in +// the certificate except for the Managed and OnDemand flags. +// (It is up to the caller to set those.) +func makeCertificateFromDisk(certFile, keyFile string) (Certificate, error) { + certPEMBlock, err := ioutil.ReadFile(certFile) + if err != nil { + return Certificate{}, err + } + keyPEMBlock, err := ioutil.ReadFile(keyFile) + if err != nil { + return Certificate{}, err + } + return makeCertificate(certPEMBlock, keyPEMBlock) +} + +// makeCertificate turns a certificate PEM bundle and a key PEM block into +// a Certificate, with OCSP and other relevant metadata tagged with it, +// except for the OnDemand and Managed flags. It is up to the caller to +// set those properties. +func makeCertificate(certPEMBlock, keyPEMBlock []byte) (Certificate, error) { + var cert Certificate + + // Convert to a tls.Certificate + tlsCert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock) + if err != nil { + return cert, err + } + if len(tlsCert.Certificate) == 0 { + return cert, errors.New("certificate is empty") + } + cert.Certificate = &tlsCert + + // Parse leaf certificate and extract relevant metadata + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + return cert, err + } + if leaf.Subject.CommonName != "" { + cert.Names = []string{strings.ToLower(leaf.Subject.CommonName)} + } + for _, name := range leaf.DNSNames { + if name != leaf.Subject.CommonName { + cert.Names = append(cert.Names, strings.ToLower(name)) + } + } + cert.NotAfter = leaf.NotAfter + + // Staple OCSP + ocspBytes, ocspResp, err := acme.GetOCSPForCert(certPEMBlock) + if err != nil { + // An error here is not a problem because a certificate may simply + // not contain a link to an OCSP server. But we should log it anyway. + log.Printf("[WARNING] No OCSP stapling for %v: %v", cert.Names, err) + } else if ocspResp.Status == ocsp.Good { + tlsCert.OCSPStaple = ocspBytes + cert.OCSP = ocspResp + } + + return cert, nil +} + +// cacheCertificate adds cert to the in-memory cache. If the cache is +// empty, cert will be used as the default certificate. If the cache is +// full, random entries are deleted until there is room to map all the +// names on the certificate. +// +// This certificate will be keyed to the names in cert.Names. Any name +// that is already a key in the cache will be replaced with this cert. +// +// This function is safe for concurrent use. +func cacheCertificate(cert Certificate) { + certCacheMu.Lock() + if _, ok := certCache[""]; !ok { + certCache[""] = cert // use as default + } + for len(certCache)+len(cert.Names) > 10000 { + // for simplicity, just remove random elements + for key := range certCache { + if key == "" { // ... but not the default cert + continue + } + delete(certCache, key) + break + } + } + for _, name := range cert.Names { + certCache[name] = cert + } + certCacheMu.Unlock() +} diff --git a/caddy/https/client.go b/caddy/https/client.go new file mode 100644 index 000000000..b47fd57f3 --- /dev/null +++ b/caddy/https/client.go @@ -0,0 +1,215 @@ +package https + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net" + "sync" + "time" + + "github.com/mholt/caddy/server" + "github.com/xenolf/lego/acme" +) + +// acmeMu ensures that only one ACME challenge occurs at a time. +var acmeMu sync.Mutex + +// ACMEClient is an acme.Client with custom state attached. +type ACMEClient struct { + *acme.Client + AllowPrompts bool // if false, we assume AlternatePort must be used +} + +// NewACMEClient creates a new ACMEClient given an email and whether +// prompting the user is allowed. Clients should not be kept and +// re-used over long periods of time, but immediate re-use is more +// efficient than re-creating on every iteration. +var NewACMEClient = func(email string, allowPrompts bool) (*ACMEClient, error) { + // Look up or create the LE user account + leUser, err := getUser(email) + if err != nil { + return nil, err + } + + // The client facilitates our communication with the CA server. + client, err := acme.NewClient(CAUrl, &leUser, rsaKeySizeToUse) + if err != nil { + return nil, err + } + + // If not registered, the user must register an account with the CA + // and agree to terms + if leUser.Registration == nil { + reg, err := client.Register() + if err != nil { + return nil, errors.New("registration error: " + err.Error()) + } + leUser.Registration = reg + + if allowPrompts { // can't prompt a user who isn't there + if !Agreed && reg.TosURL == "" { + Agreed = promptUserAgreement(saURL, false) // TODO - latest URL + } + if !Agreed && reg.TosURL == "" { + return nil, errors.New("user must agree to terms") + } + } + + err = client.AgreeToTOS() + if err != nil { + saveUser(leUser) // Might as well try, right? + return nil, errors.New("error agreeing to terms: " + err.Error()) + } + + // save user to the file system + err = saveUser(leUser) + if err != nil { + return nil, errors.New("could not save user: " + err.Error()) + } + } + + return &ACMEClient{ + Client: client, + AllowPrompts: allowPrompts, + }, nil +} + +// NewACMEClientGetEmail creates a new ACMEClient and gets an email +// address at the same time (a server config is required, since it +// may contain an email address in it). +func NewACMEClientGetEmail(config server.Config, allowPrompts bool) (*ACMEClient, error) { + return NewACMEClient(getEmail(config, allowPrompts), allowPrompts) +} + +// Configure configures c according to bindHost, which is the host (not +// whole address) to bind the listener to in solving the http and tls-sni +// challenges. +func (c *ACMEClient) Configure(bindHost string) { + // If we allow prompts, operator must be present. In our case, + // that is synonymous with saying the server is not already + // started. So if the user is still there, we don't use + // AlternatePort because we don't need to proxy the challenges. + // Conversely, if the operator is not there, the server has + // already started and we need to proxy the challenge. + if c.AllowPrompts { + // Operator is present; server is not already listening + c.SetHTTPAddress(net.JoinHostPort(bindHost, "")) + c.SetTLSAddress(net.JoinHostPort(bindHost, "")) + //c.ExcludeChallenges([]acme.Challenge{acme.DNS01}) + } else { + // Operator is not present; server is started, so proxy challenges + c.SetHTTPAddress(net.JoinHostPort(bindHost, AlternatePort)) + c.SetTLSAddress(net.JoinHostPort(bindHost, AlternatePort)) + //c.ExcludeChallenges([]acme.Challenge{acme.TLSSNI01, acme.DNS01}) + } + c.ExcludeChallenges([]acme.Challenge{acme.TLSSNI01, acme.DNS01}) // TODO: can we proxy TLS challenges? and we should support DNS... +} + +// Obtain obtains a single certificate for names. It stores the certificate +// on the disk if successful. +func (c *ACMEClient) Obtain(names []string) error { +Attempts: + for attempts := 0; attempts < 2; attempts++ { + acmeMu.Lock() + certificate, failures := c.ObtainCertificate(names, true, nil) + acmeMu.Unlock() + if len(failures) > 0 { + // Error - try to fix it or report it to the user and abort + var errMsg string // we'll combine all the failures into a single error message + var promptedForAgreement bool // only prompt user for agreement at most once + + for errDomain, obtainErr := range failures { + // TODO: Double-check, will obtainErr ever be nil? + if tosErr, ok := obtainErr.(acme.TOSError); ok { + // Terms of Service agreement error; we can probably deal with this + if !Agreed && !promptedForAgreement && c.AllowPrompts { + Agreed = promptUserAgreement(tosErr.Detail, true) // TODO: Use latest URL + promptedForAgreement = true + } + if Agreed || !c.AllowPrompts { + err := c.AgreeToTOS() + if err != nil { + return errors.New("error agreeing to updated terms: " + err.Error()) + } + continue Attempts + } + } + + // If user did not agree or it was any other kind of error, just append to the list of errors + errMsg += "[" + errDomain + "] failed to get certificate: " + obtainErr.Error() + "\n" + } + return errors.New(errMsg) + } + + // Success - immediately save the certificate resource + err := saveCertResource(certificate) + if err != nil { + return fmt.Errorf("error saving assets for %v: %v", names, err) + } + + break + } + + return nil +} + +// Renew renews the managed certificate for name. Right now our storage +// mechanism only supports one name per certificate, so this function only +// accepts one domain as input. It can be easily modified to support SAN +// certificates if, one day, they become desperately needed enough that our +// storage mechanism is upgraded to be more complex to support SAN certs. +// +// Anyway, this function is safe for concurrent use. +func (c *ACMEClient) Renew(name string) error { + // Prepare for renewal (load PEM cert, key, and meta) + certBytes, err := ioutil.ReadFile(storage.SiteCertFile(name)) + if err != nil { + return err + } + keyBytes, err := ioutil.ReadFile(storage.SiteKeyFile(name)) + if err != nil { + return err + } + metaBytes, err := ioutil.ReadFile(storage.SiteMetaFile(name)) + if err != nil { + return err + } + var certMeta acme.CertificateResource + err = json.Unmarshal(metaBytes, &certMeta) + certMeta.Certificate = certBytes + certMeta.PrivateKey = keyBytes + + // Perform renewal and retry if necessary, but not too many times. + var newCertMeta acme.CertificateResource + var success bool + for attempts := 0; attempts < 2; attempts++ { + acmeMu.Lock() + newCertMeta, err = c.RenewCertificate(certMeta, true) + acmeMu.Unlock() + if err == nil { + success = true + break + } + + // If the legal terms changed and need to be agreed to again, + // we can handle that. + if _, ok := err.(acme.TOSError); ok { + err := c.AgreeToTOS() + if err != nil { + return err + } + continue + } + + // For any other kind of error, wait 10s and try again. + time.Sleep(10 * time.Second) + } + + if !success { + return errors.New("too many renewal attempts; last error: " + err.Error()) + } + + return saveCertResource(newCertMeta) +} diff --git a/caddy/letsencrypt/crypto.go b/caddy/https/crypto.go similarity index 97% rename from caddy/letsencrypt/crypto.go rename to caddy/https/crypto.go index 95f2069de..efc40d434 100644 --- a/caddy/letsencrypt/crypto.go +++ b/caddy/https/crypto.go @@ -1,4 +1,4 @@ -package letsencrypt +package https import ( "crypto/rsa" diff --git a/caddy/letsencrypt/crypto_test.go b/caddy/https/crypto_test.go similarity index 98% rename from caddy/letsencrypt/crypto_test.go rename to caddy/https/crypto_test.go index 672095d90..875f2d217 100644 --- a/caddy/letsencrypt/crypto_test.go +++ b/caddy/https/crypto_test.go @@ -1,4 +1,4 @@ -package letsencrypt +package https import ( "bytes" diff --git a/caddy/letsencrypt/handler.go b/caddy/https/handler.go similarity index 98% rename from caddy/letsencrypt/handler.go rename to caddy/https/handler.go index e147e00c8..5b7fa0118 100644 --- a/caddy/letsencrypt/handler.go +++ b/caddy/https/handler.go @@ -1,4 +1,4 @@ -package letsencrypt +package https import ( "crypto/tls" diff --git a/caddy/letsencrypt/handler_test.go b/caddy/https/handler_test.go similarity index 98% rename from caddy/letsencrypt/handler_test.go rename to caddy/https/handler_test.go index ac6f48001..016799ffb 100644 --- a/caddy/letsencrypt/handler_test.go +++ b/caddy/https/handler_test.go @@ -1,4 +1,4 @@ -package letsencrypt +package https import ( "net" diff --git a/caddy/https/handshake.go b/caddy/https/handshake.go new file mode 100644 index 000000000..e06e7d0da --- /dev/null +++ b/caddy/https/handshake.go @@ -0,0 +1,237 @@ +package https + +import ( + "bytes" + "crypto/tls" + "encoding/pem" + "errors" + "fmt" + "log" + "sync" + "time" + + "github.com/mholt/caddy/server" + "github.com/xenolf/lego/acme" +) + +// GetCertificate gets a certificate to satisfy clientHello as long as +// the certificate is already cached in memory. +// +// This function is safe for use as a tls.Config.GetCertificate callback. +func GetCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert, err := getCertDuringHandshake(clientHello.ServerName, false) + return cert.Certificate, err +} + +// GetOrObtainCertificate will get a certificate to satisfy clientHello, even +// if that means obtaining a new certificate from a CA during the handshake. +// It first checks the in-memory cache, then accesses disk, then accesses the +// network if it must. An obtained certificate will be stored on disk and +// cached in memory. +// +// This function is safe for use as a tls.Config.GetCertificate callback. +func GetOrObtainCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert, err := getCertDuringHandshake(clientHello.ServerName, true) + return cert.Certificate, err +} + +// getCertDuringHandshake will get a certificate for name. It first tries +// the in-memory cache, then, if obtainIfNecessary is true, it goes to disk, +// then asks the CA for a certificate if necessary. +// +// This function is safe for concurrent use. +func getCertDuringHandshake(name string, obtainIfNecessary bool) (Certificate, error) { + // First check our in-memory cache to see if we've already loaded it + cert, ok := getCertificate(name) + if ok { + return cert, nil + } + + if obtainIfNecessary { + // TODO: Mitigate abuse! + var err error + + // Then check to see if we have one on disk + cert, err := cacheManagedCertificate(name, true) + if err != nil { + return cert, err + } else if cert.Certificate != nil { + cert, err := handshakeMaintenance(name, cert) + if err != nil { + log.Printf("[ERROR] Maintaining newly-loaded certificate for %s: %v", name, err) + } + return cert, err + } + + // Only option left is to get one from LE, but the name has to qualify first + if !HostQualifies(name) { + return cert, errors.New("hostname '" + name + "' does not qualify for certificate") + } + + // By this point, we need to obtain one from the CA. + return obtainOnDemandCertificate(name) + } + + return Certificate{}, nil +} + +// obtainOnDemandCertificate obtains a certificate for name for the given +// clientHello. If another goroutine has already started obtaining a cert +// for name, it will wait and use what the other goroutine obtained. +// +// This function is safe for use by multiple concurrent goroutines. +func obtainOnDemandCertificate(name string) (Certificate, error) { + // We must protect this process from happening concurrently, so synchronize. + obtainCertWaitChansMu.Lock() + wait, ok := obtainCertWaitChans[name] + if ok { + // lucky us -- another goroutine is already obtaining the certificate. + // wait for it to finish obtaining the cert and then we'll use it. + obtainCertWaitChansMu.Unlock() + <-wait + return getCertDuringHandshake(name, false) // passing in true might result in infinite loop if obtain failed + } + + // looks like it's up to us to do all the work and obtain the cert + wait = make(chan struct{}) + obtainCertWaitChans[name] = wait + obtainCertWaitChansMu.Unlock() + + // Unblock waiters and delete waitgroup when we return + defer func() { + obtainCertWaitChansMu.Lock() + close(wait) + delete(obtainCertWaitChans, name) + obtainCertWaitChansMu.Unlock() + }() + + log.Printf("[INFO] Obtaining new certificate for %s", name) + + // obtain cert + client, err := NewACMEClientGetEmail(server.Config{}, false) + if err != nil { + return Certificate{}, errors.New("error creating client: " + err.Error()) + } + client.Configure("") // TODO: which BindHost? + err = client.Obtain([]string{name}) + if err != nil { + return Certificate{}, err + } + + // The certificate is on disk; now just start over to load it and serve it + return getCertDuringHandshake(name, false) // pass in false as a fail-safe from infinite-looping +} + +// handshakeMaintenance performs a check on cert for expiration and OCSP +// validity. +// +// This function is safe for use by multiple concurrent goroutines. +func handshakeMaintenance(name string, cert Certificate) (Certificate, error) { + // fmt.Println("ON-DEMAND CERT?", cert.OnDemand) + // if !cert.OnDemand { + // return cert, nil + // } + fmt.Println("Checking expiration of cert; on-demand:", cert.OnDemand) + + // Check cert expiration + timeLeft := cert.NotAfter.Sub(time.Now().UTC()) + if timeLeft < renewDurationBefore { + log.Printf("[INFO] Certificate for %v expires in %v; attempting renewal", cert.Names, timeLeft) + return renewDynamicCertificate(name) + } + + // Check OCSP staple validity + if cert.OCSP != nil { + refreshTime := cert.OCSP.ThisUpdate.Add(cert.OCSP.NextUpdate.Sub(cert.OCSP.ThisUpdate) / 2) + if time.Now().After(refreshTime) { + err := stapleOCSP(&cert, nil) + if err != nil { + // An error with OCSP stapling is not the end of the world, and in fact, is + // quite common considering not all certs have issuer URLs that support it. + log.Printf("[ERROR] Getting OCSP for %s: %v", name, err) + } + certCacheMu.Lock() + certCache[name] = cert + certCacheMu.Unlock() + } + } + + return cert, nil +} + +// renewDynamicCertificate renews currentCert using the clientHello. It returns the +// certificate to use and an error, if any. currentCert may be returned even if an +// error occurs, since we perform renewals before they expire and it may still be +// usable. name should already be lower-cased before calling this function. +// +// This function is safe for use by multiple concurrent goroutines. +func renewDynamicCertificate(name string) (Certificate, error) { + obtainCertWaitChansMu.Lock() + wait, ok := obtainCertWaitChans[name] + if ok { + // lucky us -- another goroutine is already renewing the certificate. + // wait for it to finish, then we'll use the new one. + obtainCertWaitChansMu.Unlock() + <-wait + return getCertDuringHandshake(name, false) + } + + // looks like it's up to us to do all the work and renew the cert + wait = make(chan struct{}) + obtainCertWaitChans[name] = wait + obtainCertWaitChansMu.Unlock() + + // unblock waiters and delete waitgroup when we return + defer func() { + obtainCertWaitChansMu.Lock() + close(wait) + delete(obtainCertWaitChans, name) + obtainCertWaitChansMu.Unlock() + }() + + log.Printf("[INFO] Renewing certificate for %s", name) + + client, err := NewACMEClient("", false) // renewals don't use email + if err != nil { + return Certificate{}, err + } + client.Configure("") // TODO: Bind address of relevant listener, yuck + err = client.Renew(name) + if err != nil { + return Certificate{}, err + } + + return getCertDuringHandshake(name, false) +} + +// stapleOCSP staples OCSP information to cert for hostname name. +// If you have it handy, you should pass in the PEM-encoded certificate +// bundle; otherwise the DER-encoded cert will have to be PEM-encoded. +// If you don't have the PEM blocks handy, just pass in nil. +// +// Errors here are not necessarily fatal, it could just be that the +// certificate doesn't have an issuer URL. +func stapleOCSP(cert *Certificate, pemBundle []byte) error { + if pemBundle == nil { + // The function in the acme package that gets OCSP requires a PEM-encoded cert + bundle := new(bytes.Buffer) + for _, derBytes := range cert.Certificate.Certificate { + pem.Encode(bundle, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + } + pemBundle = bundle.Bytes() + } + + ocspBytes, ocspResp, err := acme.GetOCSPForCert(pemBundle) + if err != nil { + return err + } + + cert.Certificate.OCSPStaple = ocspBytes + cert.OCSP = ocspResp + + return nil +} + +// obtainCertWaitChans is used to coordinate obtaining certs for each hostname. +var obtainCertWaitChans = make(map[string]chan struct{}) +var obtainCertWaitChansMu sync.Mutex diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/https/https.go similarity index 59% rename from caddy/letsencrypt/letsencrypt.go rename to caddy/https/https.go index d6fb9cc37..2dd1bea39 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/https/https.go @@ -1,12 +1,12 @@ -// Package letsencrypt integrates Let's Encrypt functionality into Caddy -// with first-class support for creating and renewing certificates -// automatically. It is designed to configure sites for HTTPS by default. -package letsencrypt +// Package https facilitates the management of TLS assets and integrates +// Let's Encrypt functionality into Caddy with first-class support for +// creating and renewing certificates automatically. It is designed to +// configure sites for HTTPS by default. +package https import ( "encoding/json" "errors" - "fmt" "io/ioutil" "net" "net/http" @@ -14,9 +14,6 @@ import ( "strings" "time" - "golang.org/x/crypto/ocsp" - - "github.com/mholt/caddy/caddy/setup" "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/middleware/redirect" "github.com/mholt/caddy/server" @@ -38,34 +35,27 @@ import ( // // Also note that calling this function activates asset // management automatically, which keeps certificates -// renewed and OCSP stapling updated. This has the effect -// of causing restarts when assets are updated. +// renewed and OCSP stapling updated. // // Activate returns the updated list of configs, since // some may have been appended, for example, to redirect // plaintext HTTP requests to their HTTPS counterpart. -// This function only appends; it does not prepend or splice. +// This function only appends; it does not splice. func Activate(configs []server.Config) ([]server.Config, error) { // just in case previous caller forgot... Deactivate() - // reset cached ocsp from any previous activations - ocspCache = make(map[*[]byte]*ocsp.Response) - // pre-screen each config and earmark the ones that qualify for managed TLS MarkQualified(configs) // place certificates and keys on disk - err := ObtainCerts(configs, "") + err := ObtainCerts(configs, true) if err != nil { return configs, err } // update TLS configurations - EnableTLS(configs) - - // enable OCSP stapling (this affects all TLS-enabled configs) - err = StapleOCSP(configs) + err = EnableTLS(configs, true) if err != nil { return configs, err } @@ -78,17 +68,18 @@ func Activate(configs []server.Config) ([]server.Config, error) { // the renewal ticker is reset, so if restarts happen more often than // the ticker interval, renewals would never happen. but doing // it right away at start guarantees that renewals aren't missed. - renewCertificates(configs, false) + client, err := NewACMEClient("", true) // renewals don't use email + if err != nil { + return configs, err + } + client.Configure("") + err = renewManagedCertificates(client) + if err != nil { + return configs, err + } // keep certificates renewed and OCSP stapling updated - go maintainAssets(configs, stopChan) - - // TODO - experimental dynamic TLS! - for i := range configs { - if configs[i].Host == "" && configs[i].Port == "443" { - configs[i].TLS.Enabled = true - } - } + go maintainAssets(stopChan) return configs, nil } @@ -121,11 +112,16 @@ func MarkQualified(configs []server.Config) { // ObtainCerts obtains certificates for all these configs as long as a certificate does not // already exist on disk. It does not modify the configs at all; it only obtains and stores // certificates and keys to the disk. -func ObtainCerts(configs []server.Config, altPort string) error { - groupedConfigs := groupConfigsByEmail(configs, altPort != "") // don't prompt user if server already running +func ObtainCerts(configs []server.Config, allowPrompts bool) error { + // We group configs by email so we don't make the same clients over and + // over. This has the potential to prompt the user for an email, but we + // prevent that by assuming that if we already have a listener that can + // proxy ACME challenge requests, then the server is already running and + // the operator is no longer present. + groupedConfigs := groupConfigsByEmail(configs, allowPrompts) for email, group := range groupedConfigs { - client, err := newClientPort(email, altPort) + client, err := NewACMEClient(email, allowPrompts) if err != nil { return errors.New("error creating client: " + err.Error()) } @@ -135,7 +131,9 @@ func ObtainCerts(configs []server.Config, altPort string) error { continue } - err := clientObtain(client, []string{cfg.Host}, altPort == "") + client.Configure(cfg.BindHost) + + err := client.Obtain([]string{cfg.Host}) if err != nil { return err } @@ -147,15 +145,14 @@ func ObtainCerts(configs []server.Config, altPort string) error { // groupConfigsByEmail groups configs by the email address to be used by its // ACME client. It only includes configs that are marked as fully managed. -// This is the function that may prompt for an email address, unless skipPrompt -// is true, in which case it will assume an empty email address. -func groupConfigsByEmail(configs []server.Config, skipPrompt bool) map[string][]server.Config { +// If userPresent is true, the operator MAY be prompted for an email address. +func groupConfigsByEmail(configs []server.Config, userPresent bool) map[string][]server.Config { initMap := make(map[string][]server.Config) for _, cfg := range configs { if !cfg.TLS.Managed { continue } - leEmail := getEmail(cfg, skipPrompt) + leEmail := getEmail(cfg, userPresent) initMap[leEmail] = append(initMap[leEmail], cfg) } return initMap @@ -163,50 +160,24 @@ func groupConfigsByEmail(configs []server.Config, skipPrompt bool) map[string][] // EnableTLS configures each config to use TLS according to default settings. // It will only change configs that are marked as managed, and assumes that -// certificates and keys are already on disk. -func EnableTLS(configs []server.Config) { +// certificates and keys are already on disk. If loadCertificates is true, +// the certificates will be loaded from disk into the cache for this process +// to use. If false, TLS will still be enabled and configured with default +// settings, but no certificates will be parsed loaded into the cache, and +// the returned error value will always be nil. +func EnableTLS(configs []server.Config, loadCertificates bool) error { for i := 0; i < len(configs); i++ { if !configs[i].TLS.Managed { continue } configs[i].TLS.Enabled = true - if configs[i].Host != "" { - configs[i].TLS.Certificate = storage.SiteCertFile(configs[i].Host) - configs[i].TLS.Key = storage.SiteKeyFile(configs[i].Host) - } - setup.SetDefaultTLSParams(&configs[i]) - } -} - -// StapleOCSP staples OCSP responses to each config according to their certificate. -// This should work for any TLS-enabled config, not just Let's Encrypt ones. -func StapleOCSP(configs []server.Config) error { - for i := 0; i < len(configs); i++ { - if configs[i].TLS.Certificate == "" { - continue - } - - bundleBytes, err := ioutil.ReadFile(configs[i].TLS.Certificate) - if err != nil { - return errors.New("load certificate to staple ocsp: " + err.Error()) - } - - ocspBytes, ocspResp, err := acme.GetOCSPForCert(bundleBytes) - if err == nil { - // TODO: We ignore the error if it exists because some certificates - // may not have an issuer URL which we should ignore anyway, and - // sometimes we get syntax errors in the responses. To reproduce this - // behavior, start Caddy with an empty Caddyfile and -log stderr. Then - // add a host to the Caddyfile which requires a new LE certificate. - // Reload Caddy's config with SIGUSR1, and see the log report that it - // obtains the certificate, but then an error: - // getting ocsp: asn1: syntax error: sequence truncated - // But retrying the reload again sometimes solves the problem. It's flaky... - ocspCache[&bundleBytes] = ocspResp - if ocspResp.Status == ocsp.Good { - configs[i].TLS.OCSPStaple = ocspBytes + if loadCertificates && configs[i].Host != "" { + _, err := cacheManagedCertificate(configs[i].Host, false) + if err != nil { + return err } } + setDefaultTLSParams(&configs[i]) } return nil } @@ -251,8 +222,7 @@ func MakePlaintextRedirects(allConfigs []server.Config) []server.Config { // setting up the config may make it look like it // doesn't qualify even though it originally did. func ConfigQualifies(cfg server.Config) bool { - return cfg.TLS.Certificate == "" && // user could provide their own cert and key - cfg.TLS.Key == "" && + return !cfg.TLS.Manual && // user can provide own cert and key // user can force-disable automatic HTTPS for this host cfg.Scheme != "http" && @@ -297,71 +267,6 @@ func existingCertAndKey(host string) bool { return true } -// newClient creates a new ACME client to facilitate communication -// with the Let's Encrypt CA server on behalf of the user specified -// by leEmail. As part of this process, a user will be loaded from -// 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, "") -} - -// 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 { - return nil, err - } - - // The client facilitates our communication with the CA server. - client, err := acme.NewClient(CAUrl, &leUser, rsaKeySizeToUse) - if err != nil { - return nil, err - } - if port != "" { - client.SetHTTPAddress(":" + port) - client.SetTLSAddress(":" + port) - } - client.ExcludeChallenges([]acme.Challenge{acme.TLSSNI01, acme.DNS01}) // We can only guarantee http-01 at this time, but tls-01 should work if port is not custom! - - // If not registered, the user must register an account with the CA - // and agree to terms - if leUser.Registration == nil { - reg, err := client.Register() - if err != nil { - return nil, errors.New("registration error: " + err.Error()) - } - leUser.Registration = reg - - if port == "" { // can't prompt a user who isn't there - if !Agreed && reg.TosURL == "" { - Agreed = promptUserAgreement(saURL, false) // TODO - latest URL - } - if !Agreed && reg.TosURL == "" { - return nil, errors.New("user must agree to terms") - } - } - - err = client.AgreeToTOS() - if err != nil { - saveUser(leUser) // TODO: Might as well try, right? Error check? - return nil, errors.New("error agreeing to terms: " + err.Error()) - } - - // save user to the file system - err = saveUser(leUser) - if err != nil { - return nil, errors.New("could not save user: " + err.Error()) - } - } - - return client, nil -} - // saveCertResource saves the certificate resource to disk. This // includes the certificate file itself, the private key, and the // metadata file. @@ -427,61 +332,18 @@ func redirPlaintextHost(cfg server.Config) server.Config { } } -// clientObtain uses client to obtain a single certificate for domains in names. If -// the user is present to provide an email address, pass in true for allowPrompt, -// otherwise pass in false. If err == nil, the certificate (and key) will be saved -// to disk in the storage folder. -func clientObtain(client *acme.Client, names []string, allowPrompt bool) error { - certificate, failures := client.ObtainCertificate(names, true, nil) - if len(failures) > 0 { - // Error - either try to fix it or report them it to the user and abort - var errMsg string // we'll combine all the failures into a single error message - var promptedForAgreement bool // only prompt user for agreement at most once - - for errDomain, obtainErr := range failures { - // TODO: Double-check, will obtainErr ever be nil? - if tosErr, ok := obtainErr.(acme.TOSError); ok { - // Terms of Service agreement error; we can probably deal with this - if !Agreed && !promptedForAgreement && allowPrompt { // don't prompt if server is already running - Agreed = promptUserAgreement(tosErr.Detail, true) // TODO: Use latest URL - promptedForAgreement = true - } - if Agreed || !allowPrompt { - err := client.AgreeToTOS() - if err != nil { - return errors.New("error agreeing to updated terms: " + err.Error()) - } - return clientObtain(client, names, allowPrompt) - } - } - - // If user did not agree or it was any other kind of error, just append to the list of errors - errMsg += "[" + errDomain + "] failed to get certificate: " + obtainErr.Error() + "\n" - } - return errors.New(errMsg) - } - - // Success - immediately save the certificate resource - err := saveCertResource(certificate) - if err != nil { - return fmt.Errorf("error saving assets for %v: %v", names, err) - } - - return nil -} - // Revoke revokes the certificate for host via ACME protocol. func Revoke(host string) error { if !existingCertAndKey(host) { return errors.New("no certificate and key for " + host) } - email := getEmail(server.Config{Host: host}, false) + email := getEmail(server.Config{Host: host}, true) if email == "" { return errors.New("email is required to revoke") } - client, err := newClient(email) + client, err := NewACMEClient(email, true) if err != nil { return err } @@ -525,7 +387,7 @@ const ( AlternatePort = "5033" // RenewInterval is how often to check certificates for renewal. - RenewInterval = 24 * time.Hour + RenewInterval = 6 * time.Hour // OCSPInterval is how often to check if OCSP stapling needs updating. OCSPInterval = 1 * time.Hour @@ -550,8 +412,3 @@ var rsaKeySizeToUse = Rsa2048 // stopChan is used to signal the maintenance goroutine // to terminate. var stopChan chan struct{} - -// ocspCache maps certificate bundle to OCSP response. -// It is used during regular OCSP checks to see if the OCSP -// response needs to be updated. -var ocspCache = make(map[*[]byte]*ocsp.Response) diff --git a/caddy/letsencrypt/letsencrypt_test.go b/caddy/https/https_test.go similarity index 92% rename from caddy/letsencrypt/letsencrypt_test.go rename to caddy/https/https_test.go index e3ac2212e..e4efd2373 100644 --- a/caddy/letsencrypt/letsencrypt_test.go +++ b/caddy/https/https_test.go @@ -1,4 +1,4 @@ -package letsencrypt +package https import ( "io/ioutil" @@ -48,9 +48,9 @@ func TestConfigQualifies(t *testing.T) { }{ {server.Config{Host: ""}, true}, {server.Config{Host: "localhost"}, false}, + {server.Config{Host: "123.44.3.21"}, false}, {server.Config{Host: "example.com"}, true}, - {server.Config{Host: "example.com", TLS: server.TLSConfig{Certificate: "cert.pem"}}, false}, - {server.Config{Host: "example.com", TLS: server.TLSConfig{Key: "key.pem"}}, false}, + {server.Config{Host: "example.com", TLS: server.TLSConfig{Manual: true}}, false}, {server.Config{Host: "example.com", TLS: server.TLSConfig{LetsEncryptEmail: "off"}}, false}, {server.Config{Host: "example.com", TLS: server.TLSConfig{LetsEncryptEmail: "foo@bar.com"}}, true}, {server.Config{Host: "example.com", Scheme: "http"}, false}, @@ -257,27 +257,14 @@ func TestEnableTLS(t *testing.T) { server.Config{}, // not managed - no changes! } - EnableTLS(configs) + EnableTLS(configs, false) if !configs[0].TLS.Enabled { t.Errorf("Expected config 0 to have TLS.Enabled == true, but it was false") } - if configs[0].TLS.Certificate == "" { - t.Errorf("Expected config 0 to have TLS.Certificate set, but it was empty") - } - if configs[0].TLS.Key == "" { - t.Errorf("Expected config 0 to have TLS.Key set, but it was empty") - } - if configs[1].TLS.Enabled { t.Errorf("Expected config 1 to have TLS.Enabled == false, but it was true") } - if configs[1].TLS.Certificate != "" { - t.Errorf("Expected config 1 to have TLS.Certificate empty, but it was: %s", configs[1].TLS.Certificate) - } - if configs[1].TLS.Key != "" { - t.Errorf("Expected config 1 to have TLS.Key empty, but it was: %s", configs[1].TLS.Key) - } } func TestGroupConfigsByEmail(t *testing.T) { @@ -316,9 +303,9 @@ func TestMarkQualified(t *testing.T) { // TODO: TestConfigQualifies and this test share the same config list... configs := []server.Config{ {Host: "localhost"}, + {Host: "123.44.3.21"}, {Host: "example.com"}, - {Host: "example.com", TLS: server.TLSConfig{Certificate: "cert.pem"}}, - {Host: "example.com", TLS: server.TLSConfig{Key: "key.pem"}}, + {Host: "example.com", TLS: server.TLSConfig{Manual: true}}, {Host: "example.com", TLS: server.TLSConfig{LetsEncryptEmail: "off"}}, {Host: "example.com", TLS: server.TLSConfig{LetsEncryptEmail: "foo@bar.com"}}, {Host: "example.com", Scheme: "http"}, diff --git a/caddy/https/maintain.go b/caddy/https/maintain.go new file mode 100644 index 000000000..03d841c72 --- /dev/null +++ b/caddy/https/maintain.go @@ -0,0 +1,168 @@ +package https + +import ( + "log" + "time" + + "golang.org/x/crypto/ocsp" +) + +// maintainAssets is a permanently-blocking function +// that loops indefinitely and, on a regular schedule, checks +// certificates for expiration and initiates a renewal of certs +// that are expiring soon. It also updates OCSP stapling and +// performs other maintenance of assets. +// +// You must pass in the channel which you'll close when +// maintenance should stop, to allow this goroutine to clean up +// after itself and unblock. +func maintainAssets(stopChan chan struct{}) { + renewalTicker := time.NewTicker(RenewInterval) + ocspTicker := time.NewTicker(OCSPInterval) + + for { + select { + case <-renewalTicker.C: + log.Println("[INFO] Scanning for expiring certificates") + client, err := NewACMEClient("", false) // renewals don't use email + if err != nil { + log.Printf("[ERROR] Creating client for renewals: %v", err) + continue + } + client.Configure("") // TODO: Bind address of relevant listener, yuck + renewManagedCertificates(client) + log.Println("[INFO] Done checking certificates") + case <-ocspTicker.C: + log.Println("[INFO] Scanning for stale OCSP staples") + updatePreloadedOCSPStaples() + log.Println("[INFO] Done checking OCSP staples") + case <-stopChan: + renewalTicker.Stop() + ocspTicker.Stop() + log.Println("[INFO] Stopped background maintenance routine") + return + } + } +} + +func renewManagedCertificates(client *ACMEClient) error { + var renewed, deleted []Certificate + visitedNames := make(map[string]struct{}) + + certCacheMu.RLock() + for name, cert := range certCache { + if !cert.Managed { + continue + } + + // the list of names on this cert should never be empty... + if cert.Names == nil || len(cert.Names) == 0 { + log.Printf("[WARNING] Certificate keyed by '%s' has no names: %v", name, cert.Names) + deleted = append(deleted, cert) + continue + } + + // skip names whose certificate we've already renewed + if _, ok := visitedNames[name]; ok { + continue + } + for _, name := range cert.Names { + visitedNames[name] = struct{}{} + } + + timeLeft := cert.NotAfter.Sub(time.Now().UTC()) + if timeLeft < renewDurationBefore { + log.Printf("[INFO] Certificate for %v expires in %v; attempting renewal", cert.Names, timeLeft) + err := client.Renew(cert.Names[0]) // managed certs better have only one name + if err != nil { + if client.AllowPrompts { + // User is present, so stop immediately and report the error + certCacheMu.RUnlock() + return err + } + log.Printf("[ERROR] %v", err) + if cert.OnDemand { + deleted = append(deleted, cert) + } + } else { + renewed = append(renewed, cert) + } + } + } + certCacheMu.RUnlock() + + // Apply changes to the cache + for _, cert := range renewed { + _, err := cacheManagedCertificate(cert.Names[0], cert.OnDemand) + if err != nil { + if client.AllowPrompts { + return err // operator is present, so report error immediately + } + log.Printf("[ERROR] %v", err) + } + } + for _, cert := range deleted { + certCacheMu.Lock() + for _, name := range cert.Names { + delete(certCache, name) + } + certCacheMu.Unlock() + } + + return nil +} + +func updatePreloadedOCSPStaples() { + // Create a temporary place to store updates + // until we release the potentially slow read + // lock so we can use a quick write lock. + type ocspUpdate struct { + rawBytes []byte + parsedResponse *ocsp.Response + } + updated := make(map[string]ocspUpdate) + + certCacheMu.RLock() + for name, cert := range certCache { + // we update OCSP for managed and un-managed certs here, but only + // if it has OCSP stapled and only for pre-loaded certificates + if cert.OnDemand || cert.OCSP == nil { + continue + } + + // start checking OCSP staple about halfway through validity period for good measure + oldNextUpdate := cert.OCSP.NextUpdate + refreshTime := cert.OCSP.ThisUpdate.Add(oldNextUpdate.Sub(cert.OCSP.ThisUpdate) / 2) + + // only check for updated OCSP validity window if the refresh time is + // in the past and the certificate is not expired + if time.Now().After(refreshTime) && time.Now().Before(cert.NotAfter) { + err := stapleOCSP(&cert, nil) + if err != nil { + log.Printf("[ERROR] Checking OCSP for %s: %v", name, err) + continue + } + + // if the OCSP response has been updated, we use it + if oldNextUpdate != cert.OCSP.NextUpdate { + log.Printf("[INFO] Moving validity period of OCSP staple for %s from %v to %v", + name, oldNextUpdate, cert.OCSP.NextUpdate) + updated[name] = ocspUpdate{rawBytes: cert.Certificate.OCSPStaple, parsedResponse: cert.OCSP} + } + } + } + certCacheMu.RUnlock() + + // This write lock should be brief since we have all the info we need now. + certCacheMu.Lock() + for name, update := range updated { + cert := certCache[name] + cert.OCSP = update.parsedResponse + cert.Certificate.OCSPStaple = update.rawBytes + certCache[name] = cert + } + certCacheMu.Unlock() +} + +// renewDurationBefore is how long before expiration to renew certificates. +const renewDurationBefore = (24 * time.Hour) * 30 diff --git a/caddy/setup/tls.go b/caddy/https/setup.go similarity index 55% rename from caddy/setup/tls.go rename to caddy/https/setup.go index cf45278ca..592dfee59 100644 --- a/caddy/setup/tls.go +++ b/caddy/https/setup.go @@ -1,16 +1,24 @@ -package setup +package https import ( + "bytes" "crypto/tls" + "encoding/pem" + "io/ioutil" "log" + "os" + "path/filepath" "strings" + "github.com/mholt/caddy/caddy/setup" "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/server" ) -// TLS sets up the TLS configuration (but does not activate Let's Encrypt; that is handled elsewhere). -func TLS(c *Controller) (middleware.Middleware, error) { +// Setup sets up the TLS configuration and installs certificates that +// are specified by the user in the config file. All the automatic HTTPS +// stuff comes later outside of this function. +func Setup(c *setup.Controller) (middleware.Middleware, error) { if c.Scheme == "http" { c.TLS.Enabled = false log.Printf("[WARNING] TLS disabled for %s://%s.", c.Scheme, c.Address()) @@ -19,18 +27,21 @@ func TLS(c *Controller) (middleware.Middleware, error) { } for c.Next() { + var certificateFile, keyFile, loadDir string + args := c.RemainingArgs() switch len(args) { case 1: c.TLS.LetsEncryptEmail = args[0] - // user can force-disable LE activation this way + // user can force-disable managed TLS this way if c.TLS.LetsEncryptEmail == "off" { c.TLS.Enabled = false } case 2: - c.TLS.Certificate = args[0] - c.TLS.Key = args[1] + certificateFile = args[0] + keyFile = args[1] + c.TLS.Manual = true } // Optional block with extra parameters @@ -66,9 +77,9 @@ func TLS(c *Controller) (middleware.Middleware, error) { if len(c.TLS.ClientCerts) == 0 { return nil, c.ArgErr() } - // TODO: Allow this? It's a bad idea to allow HTTP. If we do this, make sure invoking tls at all (even manually) also sets up a redirect if possible? - // case "allow_http": - // c.TLS.DisableHTTPRedir = true + case "load": + c.Args(&loadDir) + c.TLS.Manual = true default: return nil, c.Errf("Unknown keyword '%s'", c.Val()) } @@ -78,18 +89,112 @@ func TLS(c *Controller) (middleware.Middleware, error) { if len(args) == 0 && !hadBlock { return nil, c.ArgErr() } + + // don't load certificates unless we're supposed to + if !c.TLS.Enabled || !c.TLS.Manual { + continue + } + + // load a single certificate and key, if specified + if certificateFile != "" && keyFile != "" { + err := cacheUnmanagedCertificatePEMFile(certificateFile, keyFile) + if err != nil { + return nil, c.Errf("Unable to load certificate and key files for %s: %v", c.Host, err) + } + log.Printf("[INFO] Successfully loaded TLS assets from %s and %s", certificateFile, keyFile) + } + + // load a directory of certificates, if specified + // modeled after haproxy: https://cbonte.github.io/haproxy-dconv/configuration-1.5.html#5.1-crt + if loadDir != "" { + err := filepath.Walk(loadDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + log.Printf("[WARNING] Unable to traverse into %s; skipping", path) + return nil + } + if info.IsDir() { + return nil + } + if strings.HasSuffix(strings.ToLower(info.Name()), ".pem") { + certBuilder, keyBuilder := new(bytes.Buffer), new(bytes.Buffer) + var foundKey bool + + bundle, err := ioutil.ReadFile(path) + if err != nil { + return err + } + + for { + // Decode next block so we can see what type it is + var derBlock *pem.Block + derBlock, bundle = pem.Decode(bundle) + if derBlock == nil { + break + } + + if derBlock.Type == "CERTIFICATE" { + // Re-encode certificate as PEM, appending to certificate chain + pem.Encode(certBuilder, derBlock) + } else if derBlock.Type == "EC PARAMETERS" { + // EC keys are composed of two blocks: parameters and key + // (parameter block should come first) + if !foundKey { + // Encode parameters + pem.Encode(keyBuilder, derBlock) + + // Key must immediately follow + derBlock, bundle = pem.Decode(bundle) + if derBlock == nil || derBlock.Type != "EC PRIVATE KEY" { + return c.Errf("%s: expected elliptic private key to immediately follow EC parameters", path) + } + pem.Encode(keyBuilder, derBlock) + foundKey = true + } + } else if derBlock.Type == "PRIVATE KEY" || strings.HasSuffix(derBlock.Type, " PRIVATE KEY") { + // RSA key + if !foundKey { + pem.Encode(keyBuilder, derBlock) + foundKey = true + } + } else { + return c.Errf("%s: unrecognized PEM block type: %s", path, derBlock.Type) + } + } + + certPEMBytes, keyPEMBytes := certBuilder.Bytes(), keyBuilder.Bytes() + if len(certPEMBytes) == 0 { + return c.Errf("%s: failed to parse PEM data", path) + } + if len(keyPEMBytes) == 0 { + return c.Errf("%s: no private key block found", path) + } + + err = cacheUnmanagedCertificatePEMBytes(certPEMBytes, keyPEMBytes) + if err != nil { + return c.Errf("%s: failed to load cert and key for %s: %v", path, c.Host, err) + } + log.Printf("[INFO] Successfully loaded TLS assets from %s", path) + } + return nil + }) + if err != nil { + return nil, err + } + } } - SetDefaultTLSParams(c.Config) + setDefaultTLSParams(c.Config) return nil, nil } -// SetDefaultTLSParams sets the default TLS cipher suites, protocol versions, +// setDefaultTLSParams sets the default TLS cipher suites, protocol versions, // and server preferences of a server.Config if they were not previously set -// (it does not overwrite; only fills in missing values). -func SetDefaultTLSParams(c *server.Config) { - // If no ciphers provided, use all that Caddy supports for the protocol +// (it does not overwrite; only fills in missing values). It will also set the +// port to 443 if not already set, TLS is enabled, TLS is manual, and the host +// does not equal localhost. +func setDefaultTLSParams(c *server.Config) { + // If no ciphers provided, use default list if len(c.TLS.Ciphers) == 0 { c.TLS.Ciphers = defaultCiphers } @@ -111,14 +216,14 @@ func SetDefaultTLSParams(c *server.Config) { // Default TLS port is 443; only use if port is not manually specified, // TLS is enabled, and the host is not localhost - if c.Port == "" && c.TLS.Enabled && c.Host != "localhost" { + if c.Port == "" && c.TLS.Enabled && !c.TLS.Manual && c.Host != "localhost" { c.Port = "443" } } -// Map of supported protocols -// SSLv3 will be not supported in future release -// HTTP/2 only supports TLS 1.2 and higher +// Map of supported protocols. +// SSLv3 will be not supported in future release. +// HTTP/2 only supports TLS 1.2 and higher. var supportedProtocols = map[string]uint16{ "ssl3.0": tls.VersionSSL30, "tls1.0": tls.VersionTLS10, diff --git a/caddy/setup/tls_test.go b/caddy/https/setup_test.go similarity index 58% rename from caddy/setup/tls_test.go rename to caddy/https/setup_test.go index 727a7996e..4ca57b823 100644 --- a/caddy/setup/tls_test.go +++ b/caddy/https/setup_test.go @@ -1,24 +1,46 @@ -package setup +package https import ( "crypto/tls" + "io/ioutil" + "log" + "os" "testing" + + "github.com/mholt/caddy/caddy/setup" ) -func TestTLSParseBasic(t *testing.T) { - c := NewTestController(`tls cert.pem key.pem`) +func TestMain(m *testing.M) { + // Write test certificates to disk before tests, and clean up + // when we're done. + err := ioutil.WriteFile(certFile, testCert, 0644) + if err != nil { + log.Fatal(err) + } + err = ioutil.WriteFile(keyFile, testKey, 0644) + if err != nil { + os.Remove(certFile) + log.Fatal(err) + } - _, err := TLS(c) + result := m.Run() + + os.Remove(certFile) + os.Remove(keyFile) + os.Exit(result) +} + +func TestSetupParseBasic(t *testing.T) { + c := setup.NewTestController(`tls ` + certFile + ` ` + keyFile + ``) + + _, err := Setup(c) if err != nil { t.Errorf("Expected no errors, got: %v", err) } // Basic checks - if c.TLS.Certificate != "cert.pem" { - t.Errorf("Expected certificate arg to be 'cert.pem', was '%s'", c.TLS.Certificate) - } - if c.TLS.Key != "key.pem" { - t.Errorf("Expected key arg to be 'key.pem', was '%s'", c.TLS.Key) + if !c.TLS.Manual { + t.Error("Expected TLS Manual=true, but was false") } if !c.TLS.Enabled { t.Error("Expected TLS Enabled=true, but was false") @@ -63,23 +85,23 @@ func TestTLSParseBasic(t *testing.T) { } } -func TestTLSParseIncompleteParams(t *testing.T) { +func TestSetupParseIncompleteParams(t *testing.T) { // Using tls without args is an error because it's unnecessary. - c := NewTestController(`tls`) - _, err := TLS(c) + c := setup.NewTestController(`tls`) + _, err := Setup(c) if err == nil { t.Error("Expected an error, but didn't get one") } } -func TestTLSParseWithOptionalParams(t *testing.T) { - params := `tls cert.crt cert.key { +func TestSetupParseWithOptionalParams(t *testing.T) { + params := `tls ` + certFile + ` ` + keyFile + ` { protocols ssl3.0 tls1.2 ciphers RSA-3DES-EDE-CBC-SHA RSA-AES256-CBC-SHA ECDHE-RSA-AES128-GCM-SHA256 }` - c := NewTestController(params) + c := setup.NewTestController(params) - _, err := TLS(c) + _, err := Setup(c) if err != nil { t.Errorf("Expected no errors, got: %v", err) } @@ -97,13 +119,13 @@ func TestTLSParseWithOptionalParams(t *testing.T) { } } -func TestTLSDefaultWithOptionalParams(t *testing.T) { +func TestSetupDefaultWithOptionalParams(t *testing.T) { params := `tls { ciphers RSA-3DES-EDE-CBC-SHA }` - c := NewTestController(params) + c := setup.NewTestController(params) - _, err := TLS(c) + _, err := Setup(c) if err != nil { t.Errorf("Expected no errors, got: %v", err) } @@ -113,7 +135,7 @@ func TestTLSDefaultWithOptionalParams(t *testing.T) { } // TODO: If we allow this... but probably not a good idea. -// func TestTLSDisableHTTPRedirect(t *testing.T) { +// func TestSetupDisableHTTPRedirect(t *testing.T) { // c := NewTestController(`tls { // allow_http // }`) @@ -126,34 +148,34 @@ func TestTLSDefaultWithOptionalParams(t *testing.T) { // } // } -func TestTLSParseWithWrongOptionalParams(t *testing.T) { +func TestSetupParseWithWrongOptionalParams(t *testing.T) { // Test protocols wrong params - params := `tls cert.crt cert.key { + params := `tls ` + certFile + ` ` + keyFile + ` { protocols ssl tls }` - c := NewTestController(params) - _, err := TLS(c) + c := setup.NewTestController(params) + _, err := Setup(c) if err == nil { t.Errorf("Expected errors, but no error returned") } // Test ciphers wrong params - params = `tls cert.crt cert.key { + params = `tls ` + certFile + ` ` + keyFile + ` { ciphers not-valid-cipher }` - c = NewTestController(params) - _, err = TLS(c) + c = setup.NewTestController(params) + _, err = Setup(c) if err == nil { t.Errorf("Expected errors, but no error returned") } } -func TestTLSParseWithClientAuth(t *testing.T) { - params := `tls cert.crt cert.key { +func TestSetupParseWithClientAuth(t *testing.T) { + params := `tls ` + certFile + ` ` + keyFile + ` { clients client_ca.crt client2_ca.crt }` - c := NewTestController(params) - _, err := TLS(c) + c := setup.NewTestController(params) + _, err := Setup(c) if err != nil { t.Errorf("Expected no errors, got: %v", err) } @@ -169,12 +191,40 @@ func TestTLSParseWithClientAuth(t *testing.T) { } // Test missing client cert file - params = `tls cert.crt cert.key { + params = `tls ` + certFile + ` ` + keyFile + ` { clients }` - c = NewTestController(params) - _, err = TLS(c) + c = setup.NewTestController(params) + _, err = Setup(c) if err == nil { t.Errorf("Expected an error, but no error returned") } } + +const ( + certFile = "test_cert.pem" + keyFile = "test_key.pem" +) + +var testCert = []byte(`-----BEGIN CERTIFICATE----- +MIIBkjCCATmgAwIBAgIJANfFCBcABL6LMAkGByqGSM49BAEwFDESMBAGA1UEAxMJ +bG9jYWxob3N0MB4XDTE2MDIxMDIyMjAyNFoXDTE4MDIwOTIyMjAyNFowFDESMBAG +A1UEAxMJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs22MtnG7 +9K1mvIyjEO9GLx7BFD0tBbGnwQ0VPsuCxC6IeVuXbQDLSiVQvFZ6lUszTlczNxVk +pEfqrM6xAupB7qN1MHMwHQYDVR0OBBYEFHxYDvAxUwL4XrjPev6qZ/BiLDs5MEQG +A1UdIwQ9MDuAFHxYDvAxUwL4XrjPev6qZ/BiLDs5oRikFjAUMRIwEAYDVQQDEwls +b2NhbGhvc3SCCQDXxQgXAAS+izAMBgNVHRMEBTADAQH/MAkGByqGSM49BAEDSAAw +RQIgRvBqbyJM2JCJqhA1FmcoZjeMocmhxQHTt1c+1N2wFUgCIQDtvrivbBPA688N +Qh3sMeAKNKPsx5NxYdoWuu9KWcKz9A== +-----END CERTIFICATE----- +`) + +var testKey = []byte(`-----BEGIN EC PARAMETERS----- +BggqhkjOPQMBBw== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIGLtRmwzYVcrH3J0BnzYbGPdWVF10i9p6mxkA4+b2fURoAoGCCqGSM49 +AwEHoUQDQgAEs22MtnG79K1mvIyjEO9GLx7BFD0tBbGnwQ0VPsuCxC6IeVuXbQDL +SiVQvFZ6lUszTlczNxVkpEfqrM6xAupB7g== +-----END EC PRIVATE KEY----- +`) diff --git a/caddy/letsencrypt/storage.go b/caddy/https/storage.go similarity index 99% rename from caddy/letsencrypt/storage.go rename to caddy/https/storage.go index 7a00aa18a..5d487837f 100644 --- a/caddy/letsencrypt/storage.go +++ b/caddy/https/storage.go @@ -1,4 +1,4 @@ -package letsencrypt +package https import ( "path/filepath" diff --git a/caddy/letsencrypt/storage_test.go b/caddy/https/storage_test.go similarity index 99% rename from caddy/letsencrypt/storage_test.go rename to caddy/https/storage_test.go index 545c46b64..85c2220eb 100644 --- a/caddy/letsencrypt/storage_test.go +++ b/caddy/https/storage_test.go @@ -1,4 +1,4 @@ -package letsencrypt +package https import ( "path/filepath" diff --git a/caddy/letsencrypt/user.go b/caddy/https/user.go similarity index 91% rename from caddy/letsencrypt/user.go rename to caddy/https/user.go index 1fac1d71d..c5a742526 100644 --- a/caddy/letsencrypt/user.go +++ b/caddy/https/user.go @@ -1,4 +1,4 @@ -package letsencrypt +package https import ( "bufio" @@ -41,7 +41,7 @@ func (u User) GetPrivateKey() *rsa.PrivateKey { // getUser loads the user with the given email from disk. // If the user does not exist, it will create a new one, // but it does NOT save new users to the disk or register -// them via ACME. +// them via ACME. It does NOT prompt the user. func getUser(email string) (User, error) { var user User @@ -72,7 +72,8 @@ func getUser(email string) (User, error) { } // saveUser persists a user's key and account registration -// to the file system. It does NOT register the user via ACME. +// to the file system. It does NOT register the user via ACME +// or prompt the user. func saveUser(user User) error { // make user account folder err := os.MkdirAll(storage.User(user.Email), 0700) @@ -99,7 +100,7 @@ func saveUser(user User) error { // with a new private key. This function does NOT save the // user to disk or register it via ACME. If you want to use // a user account that might already exist, call getUser -// instead. +// instead. It does NOT prompt the user. func newUser(email string) (User, error) { user := User{Email: email} privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySizeToUse) @@ -114,10 +115,10 @@ func newUser(email string) (User, error) { // address from the user to use for TLS for cfg. If it // cannot get an email address, it returns empty string. // (It will warn the user of the consequences of an -// empty email.) If skipPrompt is true, the user will -// NOT be prompted and an empty email will be returned -// instead. -func getEmail(cfg server.Config, skipPrompt bool) string { +// empty email.) This function MAY prompt the user for +// input. If userPresent is false, the operator will +// NOT be prompted and an empty email may be returned. +func getEmail(cfg server.Config, userPresent bool) string { // First try the tls directive from the Caddyfile leEmail := cfg.TLS.LetsEncryptEmail if leEmail == "" { @@ -135,11 +136,12 @@ func getEmail(cfg server.Config, skipPrompt bool) string { } if mostRecent == nil || dir.ModTime().After(mostRecent.ModTime()) { leEmail = dir.Name() + DefaultEmail = leEmail // save for next time } } } } - if leEmail == "" && !skipPrompt { + if leEmail == "" && userPresent { // Alas, we must bother the user and ask for an email address; // if they proceed they also agree to the SA. reader := bufio.NewReader(stdin) diff --git a/caddy/letsencrypt/user_test.go b/caddy/https/user_test.go similarity index 96% rename from caddy/letsencrypt/user_test.go rename to caddy/https/user_test.go index 765bd3d4d..5bc28b04c 100644 --- a/caddy/letsencrypt/user_test.go +++ b/caddy/https/user_test.go @@ -1,4 +1,4 @@ -package letsencrypt +package https import ( "bytes" @@ -140,13 +140,13 @@ func TestGetEmail(t *testing.T) { LetsEncryptEmail: "test1@foo.com", }, } - actual := getEmail(config, false) + actual := getEmail(config, true) if actual != "test1@foo.com" { t.Errorf("Did not get correct email from config; expected '%s' but got '%s'", "test1@foo.com", actual) } // Test2: Use default email from flag (or user previously typing it) - actual = getEmail(server.Config{}, false) + actual = getEmail(server.Config{}, true) if actual != DefaultEmail { t.Errorf("Did not get correct email from config; expected '%s' but got '%s'", DefaultEmail, actual) } @@ -158,7 +158,7 @@ func TestGetEmail(t *testing.T) { if err != nil { t.Fatalf("Could not simulate user input, error: %v", err) } - actual = getEmail(server.Config{}, false) + actual = getEmail(server.Config{}, true) if actual != "test3@foo.com" { t.Errorf("Did not get correct email from user input prompt; expected '%s' but got '%s'", "test3@foo.com", actual) } @@ -189,7 +189,7 @@ func TestGetEmail(t *testing.T) { t.Fatalf("Could not change user folder mod time for '%s': %v", eml, err) } } - actual = getEmail(server.Config{}, false) + actual = getEmail(server.Config{}, true) if actual != "test4-3@foo.com" { t.Errorf("Did not get correct email from storage; expected '%s' but got '%s'", "test4-3@foo.com", actual) } diff --git a/caddy/letsencrypt/handshake.go b/caddy/letsencrypt/handshake.go deleted file mode 100644 index 690eb0767..000000000 --- a/caddy/letsencrypt/handshake.go +++ /dev/null @@ -1,99 +0,0 @@ -package letsencrypt - -import ( - "crypto/tls" - "errors" - "strings" - "sync" - - "github.com/mholt/caddy/server" -) - -// GetCertificateDuringHandshake is a function that gets a certificate during a TLS handshake. -// It first checks an in-memory cache in case the cert was requested before, then tries to load -// a certificate in the storage folder from disk. If it can't find an existing certificate, it -// will try to obtain one using ACME, which will then be stored on disk and cached in memory. -// -// This function is safe for use by multiple concurrent goroutines. -func GetCertificateDuringHandshake(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { - // Utility function to help us load a cert from disk and put it in the cache if successful - loadCertFromDisk := func(domain string) *tls.Certificate { - cert, err := tls.LoadX509KeyPair(storage.SiteCertFile(domain), storage.SiteKeyFile(domain)) - if err == nil { - certCacheMu.Lock() - if len(certCache) < 10000 { // limit size of cache to prevent a ridiculous, unusual kind of attack - certCache[domain] = &cert - } - certCacheMu.Unlock() - return &cert - } - return nil - } - - // First check our in-memory cache to see if we've already loaded it - certCacheMu.RLock() - cert := server.GetCertificateFromCache(clientHello, certCache) - certCacheMu.RUnlock() - if cert != nil { - return cert, nil - } - - // Then check to see if we already have one on disk; if we do, add it to cache and use it - name := strings.ToLower(clientHello.ServerName) - cert = loadCertFromDisk(name) - if cert != nil { - return cert, nil - } - - // Only option left is to get one from LE, but the name has to qualify first - if !HostQualifies(name) { - return nil, nil - } - - // By this point, we need to obtain one from the CA. We must protect this process - // from happening concurrently, so synchronize. - obtainCertWaitGroupsMutex.Lock() - wg, ok := obtainCertWaitGroups[name] - if ok { - // lucky us -- another goroutine is already obtaining the certificate. - // wait for it to finish obtaining the cert and then we'll use it. - obtainCertWaitGroupsMutex.Unlock() - wg.Wait() - return GetCertificateDuringHandshake(clientHello) - } - - // looks like it's up to us to do all the work and obtain the cert - wg = new(sync.WaitGroup) - wg.Add(1) - obtainCertWaitGroups[name] = wg - obtainCertWaitGroupsMutex.Unlock() - - // Unblock waiters and delete waitgroup when we return - defer func() { - obtainCertWaitGroupsMutex.Lock() - wg.Done() - delete(obtainCertWaitGroups, name) - obtainCertWaitGroupsMutex.Unlock() - }() - - // obtain cert - client, err := newClientPort(DefaultEmail, AlternatePort) - if err != nil { - return nil, errors.New("error creating client: " + err.Error()) - } - err = clientObtain(client, []string{name}, false) - if err != nil { - return nil, err - } - - // load certificate into memory and return it - return loadCertFromDisk(name), nil -} - -// obtainCertWaitGroups is used to coordinate obtaining certs for each hostname. -var obtainCertWaitGroups = make(map[string]*sync.WaitGroup) -var obtainCertWaitGroupsMutex sync.Mutex - -// certCache stores certificates that have been obtained in memory. -var certCache = make(map[string]*tls.Certificate) -var certCacheMu sync.RWMutex diff --git a/caddy/letsencrypt/maintain.go b/caddy/letsencrypt/maintain.go deleted file mode 100644 index 5a59dc23a..000000000 --- a/caddy/letsencrypt/maintain.go +++ /dev/null @@ -1,180 +0,0 @@ -package letsencrypt - -import ( - "encoding/json" - "io/ioutil" - "log" - "time" - - "github.com/mholt/caddy/server" - "github.com/xenolf/lego/acme" -) - -// OnChange is a callback function that will be used to restart -// the application or the part of the application that uses -// the certificates maintained by this package. When at least -// one certificate is renewed or an OCSP status changes, this -// function will be called. -var OnChange func() error - -// maintainAssets is a permanently-blocking function -// that loops indefinitely and, on a regular schedule, checks -// certificates for expiration and initiates a renewal of certs -// that are expiring soon. It also updates OCSP stapling and -// performs other maintenance of assets. -// -// You must pass in the server configs to maintain and the channel -// which you'll close when maintenance should stop, to allow this -// goroutine to clean up after itself and unblock. -func maintainAssets(configs []server.Config, stopChan chan struct{}) { - renewalTicker := time.NewTicker(RenewInterval) - ocspTicker := time.NewTicker(OCSPInterval) - - for { - select { - case <-renewalTicker.C: - n, errs := renewCertificates(configs, true) - if len(errs) > 0 { - for _, err := range errs { - log.Printf("[ERROR] Certificate renewal: %v", 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", err) - } - } - case <-ocspTicker.C: - for bundle, oldResp := range ocspCache { - // start checking OCSP staple about halfway through validity period for good measure - refreshTime := oldResp.ThisUpdate.Add(oldResp.NextUpdate.Sub(oldResp.ThisUpdate) / 2) - - // only check for updated OCSP validity window if refreshTime is in the past - if time.Now().After(refreshTime) { - _, newResp, err := acme.GetOCSPForCert(*bundle) - if err != nil { - log.Printf("[ERROR] Checking OCSP for bundle: %v", err) - continue - } - - // we're not looking for different status, just a more future expiration - if newResp.NextUpdate != oldResp.NextUpdate { - if OnChange != nil { - log.Printf("[INFO] Updating OCSP stapling to extend validity period to %v", newResp.NextUpdate) - err := OnChange() - if err != nil { - log.Printf("[ERROR] OnChange after OCSP trigger: %v", err) - } - break - } - } - } - } - case <-stopChan: - renewalTicker.Stop() - ocspTicker.Stop() - return - } - } -} - -// renewCertificates loops through all configured site and -// looks for certificates to renew. Nothing is mutated -// 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. -// 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.Printf("[INFO] Checking certificates for %d hosts", len(configs)) - var errs []error - var n int - - for _, cfg := range configs { - // Host must be TLS-enabled and have existing assets managed by LE - if !cfg.TLS.Enabled || !existingCertAndKey(cfg.Host) { - continue - } - - // Read the certificate and get the NotAfter time. - certBytes, err := ioutil.ReadFile(storage.SiteCertFile(cfg.Host)) - if err != nil { - errs = append(errs, err) - continue // still have to check other certificates - } - expTime, err := acme.GetPEMCertExpiration(certBytes) - if err != nil { - errs = append(errs, err) - continue - } - - // The time returned from the certificate is always in UTC. - // So calculate the time left with local time as UTC. - // Directly convert it to days for the following checks. - daysLeft := int(expTime.Sub(time.Now().UTC()).Hours() / 24) - - // Renew if getting close to expiration. - if daysLeft <= renewDaysBefore { - log.Printf("[INFO] Certificate for %s has %d days remaining; attempting renewal", cfg.Host, daysLeft) - 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 - } - - // Read and set up cert meta, required for renewal - metaBytes, err := ioutil.ReadFile(storage.SiteMetaFile(cfg.Host)) - if err != nil { - errs = append(errs, err) - continue - } - privBytes, err := ioutil.ReadFile(storage.SiteKeyFile(cfg.Host)) - if err != nil { - errs = append(errs, err) - continue - } - var certMeta acme.CertificateResource - err = json.Unmarshal(metaBytes, &certMeta) - certMeta.Certificate = certBytes - certMeta.PrivateKey = privBytes - - // Renew certificate - Renew: - newCertMeta, err := client.RenewCertificate(certMeta, true) - if err != nil { - if _, ok := err.(acme.TOSError); ok { - err := client.AgreeToTOS() - if err != nil { - errs = append(errs, err) - } - goto Renew - } - - time.Sleep(10 * time.Second) - newCertMeta, err = client.RenewCertificate(certMeta, true) - if err != nil { - errs = append(errs, err) - continue - } - } - - saveCertResource(newCertMeta) - n++ - } else if daysLeft <= renewDaysBefore+7 && daysLeft >= renewDaysBefore+6 { - log.Printf("[WARNING] Certificate for %s has %d days remaining; will automatically renew when %d days remain\n", cfg.Host, daysLeft, renewDaysBefore) - } - } - - return n, errs -} - -// renewDaysBefore is how many days before expiration to renew certificates. -const renewDaysBefore = 14 diff --git a/caddy/restart.go b/caddy/restart.go index cc16568f7..c8dc8c7e2 100644 --- a/caddy/restart.go +++ b/caddy/restart.go @@ -12,7 +12,7 @@ import ( "os/exec" "path" - "github.com/mholt/caddy/caddy/letsencrypt" + "github.com/mholt/caddy/caddy/https" ) func init() { @@ -133,13 +133,15 @@ func getCertsForNewCaddyfile(newCaddyfile Input) error { } // first mark the configs that are qualified for managed TLS - letsencrypt.MarkQualified(configs) + https.MarkQualified(configs) - // we must make sure port is set before we group by bind address - letsencrypt.EnableTLS(configs) + // since we group by bind address to obtain certs, we must call + // EnableTLS to make sure the port is set properly first + // (can ignore error since we aren't actually using the certs) + https.EnableTLS(configs, false) // place certs on the disk - err = letsencrypt.ObtainCerts(configs, letsencrypt.AlternatePort) + err = https.ObtainCerts(configs, false) if err != nil { return errors.New("obtaining certs: " + err.Error()) } diff --git a/main.go b/main.go index 813423018..d83ef09ce 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,7 @@ import ( "time" "github.com/mholt/caddy/caddy" - "github.com/mholt/caddy/caddy/letsencrypt" + "github.com/mholt/caddy/caddy/https" "github.com/xenolf/lego/acme" ) @@ -32,14 +32,14 @@ const ( func init() { caddy.TrapSignals() - flag.BoolVar(&letsencrypt.Agreed, "agree", false, "Agree to Let's Encrypt Subscriber Agreement") - flag.StringVar(&letsencrypt.CAUrl, "ca", "https://acme-v01.api.letsencrypt.org/directory", "Certificate authority ACME server") + flag.BoolVar(&https.Agreed, "agree", false, "Agree to Let's Encrypt Subscriber Agreement") + flag.StringVar(&https.CAUrl, "ca", "https://acme-v01.api.letsencrypt.org/directory", "Certificate authority ACME server") flag.StringVar(&conf, "conf", "", "Configuration file to use (default="+caddy.DefaultConfigFile+")") flag.StringVar(&cpu, "cpu", "100%", "CPU cap") - flag.StringVar(&letsencrypt.DefaultEmail, "email", "", "Default Let's Encrypt account email address") + flag.StringVar(&https.DefaultEmail, "email", "", "Default Let's Encrypt account email address") flag.DurationVar(&caddy.GracefulTimeout, "grace", 5*time.Second, "Maximum duration of graceful shutdown") flag.StringVar(&caddy.Host, "host", caddy.DefaultHost, "Default host") - flag.BoolVar(&caddy.HTTP2, "http2", true, "HTTP/2 support") // TODO: temporary flag until http2 merged into std lib + flag.BoolVar(&caddy.HTTP2, "http2", true, "HTTP/2 support") flag.StringVar(&logfile, "log", "", "Process log file") flag.StringVar(&caddy.PidFile, "pidfile", "", "Path to write pid file") flag.StringVar(&caddy.Port, "port", caddy.DefaultPort, "Default port") @@ -73,7 +73,7 @@ func main() { } if revoke != "" { - err := letsencrypt.Revoke(revoke) + err := https.Revoke(revoke) if err != nil { log.Fatal(err) } diff --git a/server/config.go b/server/config.go index 11d69e142..cae1edf56 100644 --- a/server/config.go +++ b/server/config.go @@ -65,13 +65,10 @@ func (c Config) Address() string { // TLSConfig describes how TLS should be configured and used. type TLSConfig struct { - Enabled bool - Certificate string - Key string - LetsEncryptEmail string - Managed bool // will be set to true if config qualifies for automatic, managed TLS - //DisableHTTPRedir bool // TODO: not a good idea - should we really allow it? - OCSPStaple []byte + Enabled bool + LetsEncryptEmail string + Managed bool // will be set to true if config qualifies for automatic, managed TLS + Manual bool // will be set to true if user provides the cert and key files Ciphers []uint16 ProtocolMinVersion uint16 ProtocolMaxVersion uint16 diff --git a/server/server.go b/server/server.go index 293092c6e..a0235979f 100644 --- a/server/server.go +++ b/server/server.go @@ -13,7 +13,6 @@ import ( "net/http" "os" "runtime" - "strings" "sync" "time" ) @@ -25,8 +24,9 @@ import ( // graceful termination (POSIX only). type Server struct { *http.Server - HTTP2 bool // temporary while http2 is not in std lib (TODO: remove flag when part of std lib) + HTTP2 bool // whether to enable HTTP/2 tls bool // whether this server is serving all HTTPS hosts or not + OnDemandTLS bool // whether this server supports on-demand TLS (load certs at handshake-time) vhosts map[string]virtualHost // virtual hosts keyed by their address listener ListenerFile // the listener which is bound to the socket listenerMu sync.Mutex // protects listener @@ -60,20 +60,29 @@ type OptionalCallback func(http.ResponseWriter, *http.Request) bool // as it stands, you should dispose of a server after stopping it. // The behavior of serving with a spent server is undefined. func New(addr string, configs []Config, gracefulTimeout time.Duration) (*Server, error) { - var tls bool + var useTLS, useOnDemandTLS bool if len(configs) > 0 { - tls = configs[0].TLS.Enabled + useTLS = configs[0].TLS.Enabled + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + if useTLS && host == "" && !configs[0].TLS.Manual { + useOnDemandTLS = true + } } s := &Server{ Server: &http.Server{ - Addr: addr, + Addr: addr, + TLSConfig: new(tls.Config), // TODO: Make these values configurable? // ReadTimeout: 2 * time.Minute, // WriteTimeout: 2 * time.Minute, // MaxHeaderBytes: 1 << 16, }, - tls: tls, + tls: useTLS, + OnDemandTLS: useOnDemandTLS, vhosts: make(map[string]virtualHost), startChan: make(chan struct{}), connTimeout: gracefulTimeout, @@ -168,7 +177,7 @@ func (s *Server) serve(ln ListenerFile) error { for _, vh := range s.vhosts { tlsConfigs = append(tlsConfigs, vh.config.TLS) } - return serveTLSWithSNI(s, s.listener, tlsConfigs) + return serveTLS(s, s.listener, tlsConfigs) } close(s.startChan) // unblock anyone waiting for this to start listening @@ -196,106 +205,32 @@ func (s *Server) setup() error { return nil } -// serveTLSWithSNI serves TLS with Server Name Indication (SNI) support, which allows -// multiple sites (different hostnames) to be served from the same address. It also -// supports client authentication if srv has it enabled. It blocks until s quits. -// -// This method is adapted from the std lib's net/http ServeTLS function, which was written -// by the Go Authors. It has been modified to support multiple certificate/key pairs, -// client authentication, and our custom Server type. -func serveTLSWithSNI(s *Server, ln net.Listener, tlsConfigs []TLSConfig) error { - config := cloneTLSConfig(s.TLSConfig) - - // Here we diverge from the stdlib a bit by loading multiple certs/key pairs - // then we map the server names to their certs - for _, tlsConfig := range tlsConfigs { - if tlsConfig.Certificate == "" || tlsConfig.Key == "" { - continue - } - cert, err := tls.LoadX509KeyPair(tlsConfig.Certificate, tlsConfig.Key) - if err != nil { - defer close(s.startChan) - return fmt.Errorf("loading certificate and key pair: %v", err) - } - cert.OCSPStaple = tlsConfig.OCSPStaple - config.Certificates = append(config.Certificates, cert) - } - if len(config.Certificates) > 0 { - config.BuildNameToCertificate() - } - - config.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { - // TODO: When Caddy starts, if it is to issue certs dynamically, we need - // terms agreement and an email address. make sure this is enforced at server - // start if the Caddyfile enables dynamic certificate issuance! - - // Check NameToCertificate like the std lib does in "getCertificate" (unexported, bah) - cert := GetCertificateFromCache(clientHello, config.NameToCertificate) - if cert != nil { - return cert, nil - } - - if s.SNICallback != nil { - return s.SNICallback(clientHello) - } - - return nil, nil - } - +// serveTLS serves TLS with SNI and client auth support if s has them enabled. It +// blocks until s quits. +func serveTLS(s *Server, ln net.Listener, tlsConfigs []TLSConfig) error { // Customize our TLS configuration - config.MinVersion = tlsConfigs[0].ProtocolMinVersion - config.MaxVersion = tlsConfigs[0].ProtocolMaxVersion - config.CipherSuites = tlsConfigs[0].Ciphers - config.PreferServerCipherSuites = tlsConfigs[0].PreferServerCipherSuites + s.TLSConfig.MinVersion = tlsConfigs[0].ProtocolMinVersion + s.TLSConfig.MaxVersion = tlsConfigs[0].ProtocolMaxVersion + s.TLSConfig.CipherSuites = tlsConfigs[0].Ciphers + s.TLSConfig.PreferServerCipherSuites = tlsConfigs[0].PreferServerCipherSuites // TLS client authentication, if user enabled it - err := setupClientAuth(tlsConfigs, config) + err := setupClientAuth(tlsConfigs, s.TLSConfig) if err != nil { defer close(s.startChan) return err } - s.TLSConfig = config // Create TLS listener - note that we do not replace s.listener // with this TLS listener; tls.listener is unexported and does // not implement the File() method we need for graceful restarts // on POSIX systems. - ln = tls.NewListener(ln, config) + ln = tls.NewListener(ln, s.TLSConfig) close(s.startChan) // unblock anyone waiting for this to start listening return s.Server.Serve(ln) } -// Borrowed from the Go standard library, crypto/tls pacakge, common.go. -// It has been modified to fit this program. -// Original license: -// -// Copyright 2009 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. -func GetCertificateFromCache(clientHello *tls.ClientHelloInfo, cache map[string]*tls.Certificate) *tls.Certificate { - name := strings.ToLower(clientHello.ServerName) - for len(name) > 0 && name[len(name)-1] == '.' { - name = name[:len(name)-1] - } - - // exact match? great! use it - if cert, ok := cache[name]; ok { - return cert - } - - // try replacing labels in the name with wildcards until we get a match. - labels := strings.Split(name, ".") - for i := range labels { - labels[i] = "*" - candidate := strings.Join(labels, ".") - if cert, ok := cache[candidate]; ok { - return cert - } - } - return nil -} - // Stop stops the server. It blocks until the server is // totally stopped. On POSIX systems, it will wait for // connections to close (up to a max timeout of a few @@ -482,6 +417,8 @@ func (ln tcpKeepAliveListener) File() (*os.File, error) { } // copied from net/http/transport.go +/* + TODO - remove - not necessary? func cloneTLSConfig(cfg *tls.Config) *tls.Config { if cfg == nil { return &tls.Config{} @@ -507,7 +444,7 @@ func cloneTLSConfig(cfg *tls.Config) *tls.Config { MaxVersion: cfg.MaxVersion, CurvePreferences: cfg.CurvePreferences, } -} +}*/ // ShutdownCallbacks executes all the shutdown callbacks // for all the virtualhosts in servers, and returns all the