diff options
-rw-r--r-- | caddy/assets/path_test.go | 12 | ||||
-rw-r--r-- | caddy/caddy.go | 22 | ||||
-rw-r--r-- | caddy/config.go | 24 | ||||
-rw-r--r-- | caddy/helpers.go | 2 | ||||
-rw-r--r-- | caddy/letsencrypt/crypto_test.go | 4 | ||||
-rw-r--r-- | caddy/letsencrypt/letsencrypt.go | 82 | ||||
-rw-r--r-- | caddy/letsencrypt/maintain.go (renamed from caddy/letsencrypt/renew.go) | 67 | ||||
-rw-r--r-- | caddy/letsencrypt/user.go | 23 | ||||
-rw-r--r-- | caddy/restart.go | 2 | ||||
-rw-r--r-- | main.go | 8 | ||||
-rw-r--r-- | server/config.go | 1 | ||||
-rw-r--r-- | server/server.go | 6 |
12 files changed, 189 insertions, 64 deletions
diff --git a/caddy/assets/path_test.go b/caddy/assets/path_test.go new file mode 100644 index 000000000..374f813af --- /dev/null +++ b/caddy/assets/path_test.go @@ -0,0 +1,12 @@ +package assets + +import ( + "strings" + "testing" +) + +func TestPath(t *testing.T) { + if actual := Path(); !strings.HasSuffix(actual, ".caddy") { + t.Errorf("Expected path to be a .caddy folder, got: %v", actual) + } +} diff --git a/caddy/caddy.go b/caddy/caddy.go index f6976ea0a..022cbb909 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -26,6 +26,7 @@ import ( "strings" "sync" + "github.com/mholt/caddy/caddy/letsencrypt" "github.com/mholt/caddy/server" ) @@ -90,6 +91,8 @@ const ( // In any case, an error is returned if Caddy could not be // started. func Start(cdyfile Input) error { + // TODO: What if already started -- is that an error? + var err error // Input must never be nil; try to load something @@ -104,7 +107,20 @@ func Start(cdyfile Input) error { caddyfile = cdyfile caddyfileMu.Unlock() - groupings, err := Load(path.Base(cdyfile.Path()), bytes.NewReader(cdyfile.Body())) + // load the server configs + configs, err := load(path.Base(cdyfile.Path()), bytes.NewReader(cdyfile.Body())) + if err != nil { + return err + } + + // secure all the things + configs, err = letsencrypt.Activate(configs) + if err != nil { + return err + } + + // group virtualhosts by address + groupings, err := arrangeBindings(configs) if err != nil { return err } @@ -217,11 +233,15 @@ func startServers(groupings Group) error { // Stop stops all servers. It blocks until they are all stopped. func Stop() error { + letsencrypt.Deactivate() + serversMu.Lock() for _, s := range servers { s.Stop() // TODO: error checking/reporting? } + servers = []*server.Server{} // don't reuse servers serversMu.Unlock() + return nil } diff --git a/caddy/config.go b/caddy/config.go index 5688a6db2..53432b4e9 100644 --- a/caddy/config.go +++ b/caddy/config.go @@ -7,7 +7,6 @@ import ( "net" "sync" - "github.com/mholt/caddy/caddy/letsencrypt" "github.com/mholt/caddy/caddy/parse" "github.com/mholt/caddy/caddy/setup" "github.com/mholt/caddy/middleware" @@ -20,9 +19,9 @@ const ( DefaultConfigFile = "Caddyfile" ) -// Load reads input (named filename) and parses it, returning server -// configurations grouped by listening address. -func Load(filename string, input io.Reader) (Group, error) { +// load reads input (named filename) and parses it, returning the +// server configurations in the order they appeared in the input. +func load(filename string, input io.Reader) ([]server.Config, error) { var configs []server.Config // turn off timestamp for parsing @@ -34,7 +33,7 @@ func Load(filename string, input io.Reader) (Group, error) { return nil, err } if len(serverBlocks) == 0 { - return Default() + return []server.Config{NewDefault()}, nil } // Each server block represents similar hosts/addresses. @@ -101,14 +100,7 @@ func Load(filename string, input io.Reader) (Group, error) { // restore logging settings log.SetFlags(flags) - // secure all the things - configs, err = letsencrypt.Activate(configs) - if err != nil { - return nil, err - } - - // group by address/virtualhosts - return arrangeBindings(configs) + return configs, nil } // makeOnces makes a map of directive name to sync.Once @@ -271,12 +263,6 @@ func NewDefault() server.Config { } } -// Default obtains a default config and arranges -// bindings so it's ready to use. -func Default() (Group, error) { - return arrangeBindings([]server.Config{NewDefault()}) -} - // These defaults are configurable through the command line var ( // Site root diff --git a/caddy/helpers.go b/caddy/helpers.go index d8f409708..a22e7f5cc 100644 --- a/caddy/helpers.go +++ b/caddy/helpers.go @@ -13,7 +13,7 @@ import ( ) func init() { - letsencrypt.OnRenew = func() error { return Restart(nil) } + letsencrypt.OnChange = func() error { return Restart(nil) } } // isLocalhost returns true if the string looks explicitly like a localhost address. diff --git a/caddy/letsencrypt/crypto_test.go b/caddy/letsencrypt/crypto_test.go index 938778a8d..7f791a6c3 100644 --- a/caddy/letsencrypt/crypto_test.go +++ b/caddy/letsencrypt/crypto_test.go @@ -10,14 +10,14 @@ import ( ) func init() { - rsaKeySizeToUse = 128 // makes tests faster + rsaKeySizeToUse = 128 // make tests faster; small key size OK for testing } func TestSaveAndLoadRSAPrivateKey(t *testing.T) { keyFile := "test.key" defer os.Remove(keyFile) - privateKey, err := rsa.GenerateKey(rand.Reader, 128) // small key size is OK for testing + privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySizeToUse) if err != nil { t.Fatal(err) } diff --git a/caddy/letsencrypt/letsencrypt.go b/caddy/letsencrypt/letsencrypt.go index a7aef7e83..4d58bebb5 100644 --- a/caddy/letsencrypt/letsencrypt.go +++ b/caddy/letsencrypt/letsencrypt.go @@ -18,12 +18,6 @@ import ( "github.com/xenolf/lego/acme" ) -// OnRenew is the 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, this function will be called. -var OnRenew func() error - // Activate sets up TLS for each server config in configs // as needed. It only skips the config if the cert and key // are already provided or if plaintext http is explicitly @@ -36,10 +30,15 @@ var OnRenew func() error // argument. If absent, it will use the most recent email // address from last time. If there isn't one, the user // will be prompted. If the user leaves email blank, <TODO>. +// +// Also note that calling this function activates asset +// management automatically, which <TODO>. func Activate(configs []server.Config) ([]server.Config, error) { - // First identify and configure any elligible hosts for which + // TODO: Is multiple activation (before a deactivation) an error? + + // First identify and configure any eligible hosts for which // we already have certs and keys in storage from last time. - configLen := len(configs) // avoid infinite loop since this loop appends to the slice + configLen := len(configs) // avoid infinite loop since this loop appends plaintext to the slice for i := 0; i < configLen; i++ { if existingCertAndKey(configs[i].Host) && configs[i].TLS.LetsEncryptEmail != "off" { configs = autoConfigure(&configs[i], configs) @@ -47,7 +46,7 @@ func Activate(configs []server.Config) ([]server.Config, error) { } // First renew any existing certificates that need it - processCertificateRenewal(configs) + renewCertificates(configs) // Group configs by LE email address; this will help us // reduce round-trips when getting the certs. @@ -62,19 +61,19 @@ func Activate(configs []server.Config) ([]server.Config, error) { // make client to service this email address with CA server client, err := newClient(leEmail) if err != nil { - return configs, err + return configs, errors.New("error creating client: " + err.Error()) } // client is ready, so let's get free, trusted SSL certificates! yeah! certificates, err := obtainCertificates(client, serverConfigs) if err != nil { - return configs, err + return configs, errors.New("error obtaining cert: " + err.Error()) } // ... that's it. save the certs, keys, and metadata files to disk err = saveCertsAndKeys(certificates) if err != nil { - return configs, err + return configs, errors.New("error saving assets: " + err.Error()) } // it all comes down to this: turning TLS on for all the configs @@ -83,11 +82,26 @@ func Activate(configs []server.Config) ([]server.Config, error) { } } - go keepCertificatesRenewed(configs) + stopChan = make(chan struct{}) + go maintainAssets(configs, stopChan) return configs, nil } +// Deactivate cleans up long-term, in-memory resources +// allocated by calling Activate(). Essentially, it stops +// the asset maintainer from running, meaning that certificates +// will not be renewed, OCSP staples will not be updated, etc. +func Deactivate() (err error) { + defer func() { + if rec := recover(); rec != nil { + err = errors.New("already deactivated") + } + }() + close(stopChan) + return +} + // groupConfigsByEmail groups configs by the Let's Encrypt email address // associated to them or to the default Let's Encrypt email address. If the // default email is not available, the user will be prompted to provide one. @@ -158,7 +172,10 @@ func newClient(leEmail string) (*acme.Client, error) { } // The client facilitates our communication with the CA server. - client := acme.NewClient(caURL, &leUser, rsaKeySizeToUse, exposePort) + client, err := acme.NewClient(CAUrl, &leUser, rsaKeySizeToUse, exposePort) + if err != nil { + return nil, err + } // If not registered, the user must register an account with the CA // and agree to terms @@ -169,7 +186,13 @@ func newClient(leEmail string) (*acme.Client, error) { } leUser.Registration = reg - // TODO: we can just do the agreement once: when registering, right? + if !Agreed && reg.TosURL == "" { + Agreed = promptUserAgreement("<TODO>", false) // TODO + } + 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? @@ -238,6 +261,15 @@ func saveCertsAndKeys(certificates []acme.CertificateResource) error { // autoConfigure enables TLS on cfg and appends, if necessary, a new config // to allConfigs that redirects plaintext HTTP to its new HTTPS counterpart. func autoConfigure(cfg *server.Config, allConfigs []server.Config) []server.Config { + bundleBytes, err := ioutil.ReadFile(storage.SiteCertFile(cfg.Host)) + // TODO: Handle these errors better + if err == nil { + ocsp, status, err := acme.GetOCSPForCert(bundleBytes) + ocspStatus[&bundleBytes] = status + if err == nil && status == acme.OCSPGood { + cfg.TLS.OCSPStaple = ocsp + } + } cfg.TLS.Certificate = storage.SiteCertFile(cfg.Host) cfg.TLS.Key = storage.SiteKeyFile(cfg.Host) cfg.TLS.Enabled = true @@ -331,20 +363,21 @@ var ( // Whether user has agreed to the Let's Encrypt SA Agreed bool + + // The base URL to the CA's ACME endpoint + CAUrl string ) // Some essential values related to the Let's Encrypt process const ( - // The base URL to the Let's Encrypt CA - // TODO: Staging API URL is: https://acme-staging.api.letsencrypt.org - // TODO: Production endpoint is: https://acme-v01.api.letsencrypt.org - caURL = "http://192.168.99.100:4000" - // The port to expose to the CA server for Simple HTTP Challenge exposePort = "5001" // How often to check certificates for renewal renewInterval = 24 * time.Hour + + // How often to update OCSP stapling + ocspInterval = 1 * time.Hour ) // KeySize represents the length of a key in bits. @@ -362,3 +395,12 @@ const ( // This shouldn't need to change except for in tests; // the size can be drastically reduced for speed. var rsaKeySizeToUse = RSA_2048 + +// stopChan is used to signal the maintenance goroutine +// to terminate. +var stopChan chan struct{} + +// ocspStatus maps certificate bundle to OCSP status at start. +// It is used during regular OCSP checks to see if the OCSP +// status has changed. +var ocspStatus = make(map[*[]byte]int) diff --git a/caddy/letsencrypt/renew.go b/caddy/letsencrypt/maintain.go index cd19c24e7..62af5e1ca 100644 --- a/caddy/letsencrypt/renew.go +++ b/caddy/letsencrypt/maintain.go @@ -10,32 +10,66 @@ import ( "github.com/xenolf/lego/acme" ) -// keepCertificatesRenewed is a permanently-blocking function +// 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. -func keepCertificatesRenewed(configs []server.Config) { - ticker := time.Tick(renewInterval) - for range ticker { - if n, errs := processCertificateRenewal(configs); len(errs) > 0 { - for _, err := range errs { - log.Printf("[ERROR] cert renewal: %v\n", err) +// 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. +func maintainAssets(configs []server.Config, stopChan chan struct{}) { + renewalTicker := time.NewTicker(renewInterval) + ocspTicker := time.NewTicker(ocspInterval) + + for { + select { + case <-renewalTicker.C: + if n, errs := renewCertificates(configs); len(errs) > 0 { + for _, err := range errs { + log.Printf("[ERROR] cert renewal: %v\n", err) + } + if n > 0 && OnChange != nil { + err := OnChange() + if err != nil { + log.Printf("[ERROR] onchange after cert renewal: %v\n", err) + } + } } - if n > 0 && OnRenew != nil { - err := OnRenew() - if err != nil { - log.Printf("[ERROR] onrenew callback: %v\n", err) + case <-ocspTicker.C: + for bundle, oldStatus := range ocspStatus { + _, newStatus, err := acme.GetOCSPForCert(*bundle) + if err == nil && newStatus != oldStatus && OnChange != nil { + log.Printf("[INFO] ocsp status changed from %v to %v\n", oldStatus, newStatus) + err := OnChange() + if err != nil { + log.Printf("[ERROR] onchange after ocsp update: %v\n", err) + } + break } } + case <-stopChan: + renewalTicker.Stop() + ocspTicker.Stop() + return } } } -// checkCertificateRenewal loops through all configured -// sites and looks for certificates to renew. Nothing is mutated +// renewCertificates loops through all configured site and +// looks for certificates to renew. Nothing is mutated // through this function. The changes happen directly on disk. -// It returns the number of certificates renewed and -func processCertificateRenewal(configs []server.Config) (int, []error) { +// It returns the number of certificates renewed and any errors +// that occurred. It only performs a renewal if necessary. +func renewCertificates(configs []server.Config) (int, []error) { log.Print("[INFO] Processing certificate renewals...") var errs []error var n int @@ -92,6 +126,7 @@ func processCertificateRenewal(configs []server.Config) (int, []error) { // Renew certificate. // TODO: revokeOld should be an option in the caddyfile + // TODO: bundle should be an option in the caddyfile as well :) newCertMeta, err := client.RenewCertificate(certMeta, true, true) if err != nil { time.Sleep(10 * time.Second) diff --git a/caddy/letsencrypt/user.go b/caddy/letsencrypt/user.go index 752cc510d..ff4d6acb6 100644 --- a/caddy/letsencrypt/user.go +++ b/caddy/letsencrypt/user.go @@ -156,6 +156,29 @@ func getEmail(cfg server.Config) string { return strings.TrimSpace(leEmail) } +// promptUserAgreement prompts the user to agree to the agreement +// at agreementURL via stdin. If the agreement has changed, then pass +// true as the second argument. If this is the user's first time +// agreeing, pass false. It returns whether the user agreed or not. +func promptUserAgreement(agreementURL string, changed bool) bool { + if changed { + fmt.Printf("The Let's Encrypt Subscriber Agreement has changed:\n%s\n", agreementURL) + fmt.Print("Do you agree to the new terms? (y/n): ") + } else { + fmt.Printf("To continue, you must agree to the Let's Encrypt Subscriber Agreement:\n%s\n", agreementURL) + fmt.Print("Do you agree to the terms? (y/n): ") + } + + reader := bufio.NewReader(stdin) // TODO/BUG: This doesn't work when Caddyfile is piped into caddy + answer, err := reader.ReadString('\n') + if err != nil { + return false + } + answer = strings.ToLower(strings.TrimSpace(answer)) + + return answer == "y" || answer == "yes" +} + // stdin is used to read the user's input if prompted; // this is changed by tests during tests. var stdin = io.ReadWriter(os.Stdin) diff --git a/caddy/restart.go b/caddy/restart.go index 7a07fbc1b..1c61c5a03 100644 --- a/caddy/restart.go +++ b/caddy/restart.go @@ -81,7 +81,7 @@ func Restart(newCaddyfile Input) error { wpipe.Close() // Wait for child process to signal success or fail - sigwpipe.Close() // close our copy of the write end of the pipe + sigwpipe.Close() // close our copy of the write end of the pipe or we might be stuck answer, err := ioutil.ReadAll(sigrpipe) if err != nil || len(answer) == 0 { log.Println("restart: child failed to answer; changes not applied") @@ -29,16 +29,20 @@ const ( func init() { flag.StringVar(&conf, "conf", "", "Configuration file to use (default="+caddy.DefaultConfigFile+")") - flag.BoolVar(&caddy.HTTP2, "http2", true, "Enable HTTP/2 support") // TODO: temporary flag until http2 merged into std lib + flag.BoolVar(&caddy.HTTP2, "http2", true, "HTTP/2 support") // TODO: temporary flag until http2 merged into std lib flag.BoolVar(&caddy.Quiet, "quiet", false, "Quiet mode (no initialization output)") flag.StringVar(&cpu, "cpu", "100%", "CPU cap") flag.StringVar(&caddy.Root, "root", caddy.DefaultRoot, "Root path to default site") flag.StringVar(&caddy.Host, "host", caddy.DefaultHost, "Default host") flag.StringVar(&caddy.Port, "port", caddy.DefaultPort, "Default port") flag.BoolVar(&version, "version", false, "Show version") + // TODO: Boulder dev URL is: http://192.168.99.100:4000 + // TODO: Staging API URL is: https://acme-staging.api.letsencrypt.org + // TODO: Production endpoint is: https://acme-v01.api.letsencrypt.org + flag.StringVar(&letsencrypt.CAUrl, "ca", "https://acme-staging.api.letsencrypt.org", "Certificate authority ACME server") flag.BoolVar(&letsencrypt.Agreed, "agree", false, "Agree to Let's Encrypt Subscriber Agreement") flag.StringVar(&letsencrypt.DefaultEmail, "email", "", "Default email address to use for Let's Encrypt transactions") - flag.StringVar(&revoke, "revoke", "", "Hostname for which to revoke its certificate") + flag.StringVar(&revoke, "revoke", "", "Hostname for which to revoke the certificate") } func main() { diff --git a/server/config.go b/server/config.go index dedd7ba37..a3bb5f50d 100644 --- a/server/config.go +++ b/server/config.go @@ -56,6 +56,7 @@ type TLSConfig struct { Certificate string Key string LetsEncryptEmail string + OCSPStaple []byte Ciphers []uint16 ProtocolMinVersion uint16 ProtocolMaxVersion uint16 diff --git a/server/server.go b/server/server.go index 0a4dd4bab..9e7bcb389 100644 --- a/server/server.go +++ b/server/server.go @@ -66,6 +66,7 @@ func New(addr string, configs []Config) (*Server, error) { // into sync.WaitGroup.Wait() - basically, an add // with a positive delta must be guaranteed to // occur before Wait() is called on the wg. + // In a way, this kind of acts as a safety barrier. s.httpWg.Add(1) // Set up each virtualhost @@ -179,6 +180,7 @@ func serveTLSWithSNI(s *Server, ln net.Listener, tlsConfigs []TLSConfig) error { config.Certificates = make([]tls.Certificate, len(tlsConfigs)) for i, tlsConfig := range tlsConfigs { config.Certificates[i], err = tls.LoadX509KeyPair(tlsConfig.Certificate, tlsConfig.Key) + config.Certificates[i].OCSPStaple = tlsConfig.OCSPStaple if err != nil { return err } @@ -227,12 +229,12 @@ func (s *Server) Stop() error { // Wait for remaining connections to finish or // force them all to close after timeout select { - case <-time.After(5 * time.Second): // TODO: configurable? + case <-time.After(5 * time.Second): // TODO: make configurable? case <-done: } } - // Close the listener now; this stops the server and + // Close the listener now; this stops the server without delay s.listenerMu.Lock() err := s.listener.Close() s.listenerMu.Unlock() |