diff --git a/config/config.go b/config/config.go index fee6b34e..fc5fe182 100644 --- a/config/config.go +++ b/config/config.go @@ -1,7 +1,6 @@ package config import ( - "errors" "fmt" "io" "log" @@ -13,7 +12,6 @@ import ( "github.com/mholt/caddy/config/setup" "github.com/mholt/caddy/middleware" "github.com/mholt/caddy/server" - "github.com/xenolf/lego/acme" ) const ( @@ -103,34 +101,13 @@ func Load(filename string, input io.Reader) (Group, error) { // restore logging settings log.SetFlags(flags) - // Initiate Let's Encrypt - leUser, err := NewLetsEncryptUser("example1@mail.com") + // secure all the things + err = initiateLetsEncrypt(configs) if err != nil { - return Group{}, err - } - for _, cfg := range configs { - // TODO: && !IsLoopback() - if !cfg.TLS.Enabled && cfg.Port != "http" { - client := acme.NewClient("http://192.168.99.100:4000", &leUser, 2048, "5001") - reg, err := client.Register() - if err != nil { - return Group{}, errors.New("Error Registering: " + err.Error()) - } - leUser.Registration = reg - - err = client.AgreeToTos() - if err != nil { - return Group{}, errors.New("Error Agreeing to ToS: " + err.Error()) - } - - certs, err := client.ObtainCertificates([]string{"caddy.dev"}) - if err != nil { - return Group{}, errors.New("Error Obtaining Certs: " + err.Error()) - } - } + return nil, err } - // Group by address/virtualhosts + // group by address/virtualhosts return arrangeBindings(configs) } diff --git a/config/letsencrypt.go b/config/letsencrypt.go index 53aac694..e7e2a46f 100644 --- a/config/letsencrypt.go +++ b/config/letsencrypt.go @@ -1,27 +1,214 @@ package config import ( + "bufio" "crypto/rand" "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "github.com/mholt/caddy/app" + "github.com/mholt/caddy/server" "github.com/xenolf/lego/acme" ) -func NewLetsEncryptUser(email string) (LetsEncryptUser, error) { +const rsaKeySize = 2048 + +// initiateLetsEncrypt sets up TLS ... +func initiateLetsEncrypt(configs []server.Config) error { + // fill map of email address to server configs that use that email address for TLS. + // this will help us reduce roundtrips when getting the certs. + initMap := make(map[string][]*server.Config) + for i := 0; i < len(configs); i++ { + if configs[i].TLS.Certificate == "" && configs[i].TLS.Key == "" && configs[i].Port != "http" { // TODO: && !cfg.Host.IsLoopback() + leEmail := getEmail(configs[i]) + if leEmail == "" { + return errors.New("cannot serve HTTPS without email address OR certificate and key") + } + initMap[leEmail] = append(initMap[leEmail], &configs[i]) + } + } + + for leEmail, serverConfigs := range initMap { + leUser, err := getLetsEncryptUser(leEmail) + if err != nil { + return err + } + + client := acme.NewClient("http://192.168.99.100:4000", &leUser, rsaKeySize, "5001") + + if leUser.Registration == nil { + reg, err := client.Register() + if err != nil { + return errors.New("registration error: " + err.Error()) + } + leUser.Registration = reg + + // TODO: we can just do the agreement once, when registering, right? + err = client.AgreeToTos() + if err != nil { + saveLetsEncryptUser(leUser) // TODO: Might as well try, right? Error check? + return errors.New("error agreeing to terms: " + err.Error()) + } + + err = saveLetsEncryptUser(leUser) + if err != nil { + return errors.New("could not save user: " + err.Error()) + } + } + + // collect all the hostnames + var hosts []string + for _, cfg := range serverConfigs { + hosts = append(hosts, cfg.Host) + } + + // showtime: let's get free, trusted SSL certificates! yee-haw! + certificates, err := client.ObtainCertificates(hosts) + if err != nil { + return errors.New("error obtaining certs: " + err.Error()) + } + + // ... that's it. pain gone. save the certs, keys, and update server configs. + for _, cert := range certificates { + certFolder := filepath.Join(app.DataFolder(), "letsencrypt", "sites", cert.Domain) + os.MkdirAll(certFolder, 0700) + // Save cert + err = saveCertificate(cert.Certificate, filepath.Join(certFolder, cert.Domain+".crt")) + //err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".crt"), cert.Certificate, 0600) + if err != nil { + return err + } + + // Save private key + //savePrivateKey(cert.PrivateKey, filepath.Join(certFolder, cert.Domain+".key")) + err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".key"), cert.PrivateKey, 0600) + if err != nil { + return err + } + + // Save cert metadata + jsonBytes, err := json.MarshalIndent(&CertificateMeta{URL: cert.CertURL, Domain: cert.Domain}, "", "\t") + if err != nil { + return err + } + err = ioutil.WriteFile(filepath.Join(certFolder, cert.Domain+".json"), jsonBytes, 0600) + if err != nil { + return err + } + } + + // it all comes down to this: filling in the file path of a valid certificate automatically + for _, cfg := range serverConfigs { + cfg.TLS.Certificate = filepath.Join(app.DataFolder(), "letsencrypt", "sites", cfg.Host, cfg.Host+".crt") + cfg.TLS.Key = filepath.Join(app.DataFolder(), "letsencrypt", "sites", cfg.Host, cfg.Host+".key") + } + } + + return nil +} + +func getEmail(cfg server.Config) string { + leEmail := cfg.TLS.LetsEncryptEmail + if leEmail == "" { + leEmail = LetsEncryptEmail + } + if leEmail == "" { + // TODO: get most recent email from ~/.caddy/users file + } + if leEmail == "" { + reader := bufio.NewReader(os.Stdin) + fmt.Print("Email address: ") + var err error + leEmail, err = reader.ReadString('\n') + if err != nil { + return "" + } + LetsEncryptEmail = leEmail + } + return strings.TrimSpace(leEmail) +} + +func saveLetsEncryptUser(user LetsEncryptUser) error { + // make user account folder + userFolder := filepath.Join(app.DataFolder(), "letsencrypt", "users", user.Email) + err := os.MkdirAll(userFolder, 0700) + if err != nil { + return err + } + + // save private key file + user.KeyFile = filepath.Join(userFolder, emailUsername(user.Email)+".key") + err = savePrivateKey(user.key, user.KeyFile) + if err != nil { + return err + } + + // save registration file + jsonBytes, err := json.MarshalIndent(&user, "", "\t") + if err != nil { + return err + } + + return ioutil.WriteFile(filepath.Join(userFolder, "registration.json"), jsonBytes, 0600) +} + +func getLetsEncryptUser(email string) (LetsEncryptUser, error) { + var user LetsEncryptUser + + userFolder := filepath.Join(app.DataFolder(), "letsencrypt", "users", email) + regFile, err := os.Open(filepath.Join(userFolder, "registration.json")) + if err != nil { + if os.IsNotExist(err) { + // create a new user + return newLetsEncryptUser(email) + } + return user, err + } + + err = json.NewDecoder(regFile).Decode(&user) + if err != nil { + return user, err + } + + user.key, err = loadPrivateKey(user.KeyFile) + if err != nil { + return user, err + } + + return user, nil +} + +func newLetsEncryptUser(email string) (LetsEncryptUser, error) { user := LetsEncryptUser{Email: email} - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize) if err != nil { return user, errors.New("error generating private key: " + err.Error()) } - user.Key = privateKey + user.key = privateKey return user, nil } +func emailUsername(email string) string { + at := strings.Index(email, "@") + if at == -1 { + return email + } + return email[:at] +} + type LetsEncryptUser struct { Email string Registration *acme.RegistrationResource - Key *rsa.PrivateKey + KeyFile string + key *rsa.PrivateKey } func (u LetsEncryptUser) GetEmail() string { @@ -31,5 +218,49 @@ func (u LetsEncryptUser) GetRegistration() *acme.RegistrationResource { return u.Registration } func (u LetsEncryptUser) GetPrivateKey() *rsa.PrivateKey { - return u.Key + return u.key +} + +// savePrivateKey saves an RSA private key to file. +// +// Borrowed from Sebastian Erhart +// https://github.com/xenolf/lego/blob/34910bd541315993224af1f04f9b2877513e5477/crypto.go +func savePrivateKey(key *rsa.PrivateKey, file string) error { + pemKey := pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} + keyOut, err := os.Create(file) + if err != nil { + return err + } + pem.Encode(keyOut, &pemKey) + keyOut.Close() + return nil +} + +// TODO: Check file permission +func saveCertificate(certBytes []byte, file string) error { + pemCert := pem.Block{Type: "CERTIFICATE", Bytes: certBytes} + certOut, err := os.Create(file) + if err != nil { + return err + } + pem.Encode(certOut, &pemCert) + certOut.Close() + return nil +} + +// loadPrivateKey loads an RSA private key from filename. +// +// Borrowed from Sebastian Erhart +// https://github.com/xenolf/lego/blob/34910bd541315993224af1f04f9b2877513e5477/crypto.go +func loadPrivateKey(file string) (*rsa.PrivateKey, error) { + keyBytes, err := ioutil.ReadFile(file) + if err != nil { + return nil, err + } + keyBlock, _ := pem.Decode(keyBytes) + return x509.ParsePKCS1PrivateKey(keyBlock.Bytes) +} + +type CertificateMeta struct { + Domain, URL string } diff --git a/config/setup/tls.go b/config/setup/tls.go index 785eeb5c..1ddef0b3 100644 --- a/config/setup/tls.go +++ b/config/setup/tls.go @@ -11,10 +11,7 @@ import ( func TLS(c *Controller) (middleware.Middleware, error) { if c.Port != "http" { c.TLS.Enabled = true - } - - if c.Port == "http" { - c.TLS.Enabled = false + } else { log.Printf("Warning: TLS disabled for %s://%s. To force TLS over the plaintext HTTP port, "+ "specify port 80 explicitly (https://%s:80).", c.Port, c.Host, c.Host) }