diff --git a/caddy/caddymain/run.go b/caddy/caddymain/run.go index 0aac40937..281f7d5ea 100644 --- a/caddy/caddymain/run.go +++ b/caddy/caddymain/run.go @@ -173,10 +173,12 @@ func moveStorage() { if os.IsNotExist(err) { return } - newPath, err := caddytls.StorageFor(caddytls.DefaultCAUrl) + // Just use a default config to get default (file) storage + fileStorage, err := new(caddytls.Config).StorageFor(caddytls.DefaultCAUrl) if err != nil { log.Fatalf("[ERROR] Unable to get new path for certificate storage: %v", err) } + newPath := string(fileStorage.(caddytls.FileStorage)) err = os.MkdirAll(string(newPath), 0700) if err != nil { log.Fatalf("[ERROR] Unable to make new certificate storage path: %v\n\nPlease follow instructions at:\nhttps://github.com/mholt/caddy/issues/902#issuecomment-228876011", err) diff --git a/caddytls/certificates.go b/caddytls/certificates.go index 5151d0187..d057a5e6b 100644 --- a/caddytls/certificates.go +++ b/caddytls/certificates.go @@ -92,11 +92,15 @@ func getCertificate(name string) (cert Certificate, matched, defaulted bool) { // // This function is safe for concurrent use. func CacheManagedCertificate(domain string, cfg *Config) (Certificate, error) { - storage, err := StorageFor(cfg.CAUrl) + storage, err := cfg.StorageFor(cfg.CAUrl) if err != nil { return Certificate{}, err } - cert, err := makeCertificateFromDisk(storage.SiteCertFile(domain), storage.SiteKeyFile(domain)) + siteData, err := storage.LoadSite(domain) + if err != nil { + return Certificate{}, err + } + cert, err := makeCertificate(siteData.Cert, siteData.Key) if err != nil { return cert, err } diff --git a/caddytls/client.go b/caddytls/client.go index 8324b8382..09d6425d2 100644 --- a/caddytls/client.go +++ b/caddytls/client.go @@ -4,11 +4,9 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" "log" "net" "net/url" - "os" "strings" "sync" "time" @@ -30,7 +28,7 @@ type ACMEClient struct { // newACMEClient creates a new ACMEClient given an email and whether // prompting the user is allowed. It's a variable so we can mock in tests. var newACMEClient = func(config *Config, allowPrompts bool) (*ACMEClient, error) { - storage, err := StorageFor(config.CAUrl) + storage, err := config.StorageFor(config.CAUrl) if err != nil { return nil, err } @@ -180,7 +178,7 @@ Attempts: } // Success - immediately save the certificate resource - storage, err := StorageFor(c.config.CAUrl) + storage, err := c.config.StorageFor(c.config.CAUrl) if err != nil { return err } @@ -204,28 +202,33 @@ Attempts: // Anyway, this function is safe for concurrent use. func (c *ACMEClient) Renew(name string) error { // Get access to ACME storage - storage, err := StorageFor(c.config.CAUrl) + storage, err := c.config.StorageFor(c.config.CAUrl) if err != nil { return err } + // We must lock the renewal with the storage engine + if lockObtained, err := storage.LockRegister(name); err != nil { + return err + } else if !lockObtained { + log.Printf("[INFO] Certificate for %v is already being renewed elsewhere", name) + return nil + } + defer func() { + if err := storage.UnlockRegister(name); err != nil { + log.Printf("[ERROR] Unable to unlock renewal lock for %v: %v", name, err) + } + }() + // 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)) + siteData, err := storage.LoadSite(name) if err != nil { return err } var certMeta acme.CertificateResource - err = json.Unmarshal(metaBytes, &certMeta) - certMeta.Certificate = certBytes - certMeta.PrivateKey = keyBytes + err = json.Unmarshal(siteData.Meta, &certMeta) + certMeta.Certificate = siteData.Cert + certMeta.PrivateKey = siteData.Key // Perform renewal and retry if necessary, but not too many times. var newCertMeta acme.CertificateResource @@ -265,27 +268,26 @@ func (c *ACMEClient) Renew(name string) error { // Revoke revokes the certificate for name and deltes // it from storage. func (c *ACMEClient) Revoke(name string) error { - storage, err := StorageFor(c.config.CAUrl) + storage, err := c.config.StorageFor(c.config.CAUrl) if err != nil { return err } - if !existingCertAndKey(storage, name) { + if !storage.SiteExists(name) { return errors.New("no certificate and key for " + name) } - certFile := storage.SiteCertFile(name) - certBytes, err := ioutil.ReadFile(certFile) + siteData, err := storage.LoadSite(name) if err != nil { return err } - err = c.Client.RevokeCertificate(certBytes) + err = c.Client.RevokeCertificate(siteData.Cert) if err != nil { return err } - err = os.Remove(certFile) + err = storage.DeleteSite(name) if err != nil { return errors.New("certificate revoked, but unable to delete certificate file: " + err.Error()) } diff --git a/caddytls/config.go b/caddytls/config.go index 91c745160..ea5205a07 100644 --- a/caddytls/config.go +++ b/caddytls/config.go @@ -11,6 +11,9 @@ import ( "github.com/mholt/caddy" "github.com/xenolf/lego/acme" + "log" + "net/url" + "strings" ) // Config describes how TLS should be configured and used. @@ -94,6 +97,13 @@ type Config struct { // The type of key to use when generating // certificates KeyType acme.KeyType + + // The explicitly set storage creator or nil; use + // StorageFor() to get a guaranteed non-nil Storage + // instance. Note, Caddy may call this frequently so + // implementors are encouraged to cache any heavy + // instantiations. + StorageCreator StorageCreator } // ObtainCert obtains a certificate for c.Hostname, as long as a certificate @@ -106,15 +116,28 @@ func (c *Config) ObtainCert(allowPrompts bool) error { } func (c *Config) obtainCertName(name string, allowPrompts bool) error { - storage, err := StorageFor(c.CAUrl) + storage, err := c.StorageFor(c.CAUrl) if err != nil { return err } - if !c.Managed || !HostQualifies(name) || existingCertAndKey(storage, name) { + if !c.Managed || !HostQualifies(name) || storage.SiteExists(name) { return nil } + // We must lock the obtain with the storage engine + if lockObtained, err := storage.LockRegister(name); err != nil { + return err + } else if !lockObtained { + log.Printf("[INFO] Certificate for %v is already being obtained elsewhere", name) + return nil + } + defer func() { + if err := storage.UnlockRegister(name); err != nil { + log.Printf("[ERROR] Unable to unlock obtain lock for %v: %v", name, err) + } + }() + if c.ACMEEmail == "" { c.ACMEEmail = getEmail(storage, allowPrompts) } @@ -127,34 +150,43 @@ func (c *Config) obtainCertName(name string, allowPrompts bool) error { return client.Obtain([]string{name}) } -// RenewCert renews the certificate for c.Hostname. +// RenewCert renews the certificate for c.Hostname. If there is already a lock +// on renewal, this will not perform the renewal and no error will occur. func (c *Config) RenewCert(allowPrompts bool) error { return c.renewCertName(c.Hostname, allowPrompts) } +// renewCertName renews the certificate for the given name. If there is already +// a lock on renewal, this will not perform the renewal and no error will +// occur. func (c *Config) renewCertName(name string, allowPrompts bool) error { - storage, err := StorageFor(c.CAUrl) + storage, err := c.StorageFor(c.CAUrl) if err != nil { return err } + // We must lock the renewal with the storage engine + if lockObtained, err := storage.LockRegister(name); err != nil { + return err + } else if !lockObtained { + log.Printf("[INFO] Certificate for %v is already being renewed elsewhere", name) + return nil + } + defer func() { + if err := storage.UnlockRegister(name); err != nil { + log.Printf("[ERROR] Unable to unlock renewal lock for %v: %v", name, err) + } + }() + // Prepare for renewal (load PEM cert, key, and meta) - certBytes, err := ioutil.ReadFile(storage.SiteCertFile(c.Hostname)) - if err != nil { - return err - } - keyBytes, err := ioutil.ReadFile(storage.SiteKeyFile(c.Hostname)) - if err != nil { - return err - } - metaBytes, err := ioutil.ReadFile(storage.SiteMetaFile(c.Hostname)) + siteData, err := storage.LoadSite(c.Hostname) if err != nil { return err } var certMeta acme.CertificateResource - err = json.Unmarshal(metaBytes, &certMeta) - certMeta.Certificate = certBytes - certMeta.PrivateKey = keyBytes + err = json.Unmarshal(siteData.Meta, &certMeta) + certMeta.Certificate = siteData.Cert + certMeta.PrivateKey = siteData.Key client, err := newACMEClient(c, allowPrompts) if err != nil { @@ -194,6 +226,53 @@ func (c *Config) renewCertName(name string, allowPrompts bool) error { return saveCertResource(storage, newCertMeta) } +// StorageFor obtains a TLS Storage instance for the given CA URL which should +// be unique for every different ACME CA. If a StorageCreator is set on this +// Config, it will be used. Otherwise the default file storage implementation +// is used. When the error is nil, this is guaranteed to return a non-nil +// Storage instance. +func (c *Config) StorageFor(caURL string) (Storage, error) { + // Validate CA URL + if caURL == "" { + caURL = DefaultCAUrl + } + if caURL == "" { + return nil, fmt.Errorf("cannot create storage without CA URL") + } + caURL = strings.ToLower(caURL) + + // scheme required or host will be parsed as path (as of Go 1.6) + if !strings.Contains(caURL, "://") { + caURL = "https://" + caURL + } + + u, err := url.Parse(caURL) + if err != nil { + return nil, fmt.Errorf("%s: unable to parse CA URL: %v", caURL, err) + } + + if u.Host == "" { + return nil, fmt.Errorf("%s: no host in CA URL", caURL) + } + + // Create the storage based on the URL + var s Storage + if c.StorageCreator != nil { + s, err = c.StorageCreator(u) + if err != nil { + return nil, fmt.Errorf("%s: unable to create custom storage: %v", caURL, err) + } + } + if s == nil { + // We trust that this does not return a nil s when there's a nil err + s, err = FileStorageCreator(u) + if err != nil { + return nil, fmt.Errorf("%s: unable to create file storage: %v", caURL, err) + } + } + return s, nil +} + // MakeTLSConfig reduces configs into a single tls.Config. // If TLS is to be disabled, a nil tls.Config will be returned. func MakeTLSConfig(configs []*Config) (*tls.Config, error) { diff --git a/caddytls/config_test.go b/caddytls/config_test.go new file mode 100644 index 000000000..4ca22c6a7 --- /dev/null +++ b/caddytls/config_test.go @@ -0,0 +1,130 @@ +package caddytls + +import ( + "errors" + "net/url" + "reflect" + "testing" +) + +func TestStorageForNoURL(t *testing.T) { + c := &Config{} + if _, err := c.StorageFor(""); err == nil { + t.Fatal("Expected error on empty URL") + } +} + +func TestStorageForLowercasesAndPrefixesScheme(t *testing.T) { + resultStr := "" + c := &Config{ + StorageCreator: func(caURL *url.URL) (Storage, error) { + resultStr = caURL.String() + return nil, nil + }, + } + if _, err := c.StorageFor("EXAMPLE.COM/BLAH"); err != nil { + t.Fatal(err) + } + if resultStr != "https://example.com/blah" { + t.Fatalf("Unexpected CA URL string: %v", resultStr) + } +} + +func TestStorageForBadURL(t *testing.T) { + c := &Config{} + if _, err := c.StorageFor("http://192.168.0.%31/"); err == nil { + t.Fatal("Expected error for bad URL") + } +} + +func TestStorageForDefault(t *testing.T) { + c := &Config{} + s, err := c.StorageFor("example.com") + if err != nil { + t.Fatal(err) + } + if reflect.TypeOf(s).Name() != "FileStorage" { + t.Fatalf("Unexpected storage type: %v", reflect.TypeOf(s).Name()) + } +} + +func TestStorageForCustom(t *testing.T) { + storage := fakeStorage("fake") + c := &Config{ + StorageCreator: func(caURL *url.URL) (Storage, error) { + return storage, nil + }, + } + s, err := c.StorageFor("example.com") + if err != nil { + t.Fatal(err) + } + if s != storage { + t.Fatal("Unexpected storage") + } +} + +func TestStorageForCustomError(t *testing.T) { + c := &Config{ + StorageCreator: func(caURL *url.URL) (Storage, error) { + return nil, errors.New("some error") + }, + } + if _, err := c.StorageFor("example.com"); err == nil { + t.Fatal("Expecting error") + } +} + +func TestStorageForCustomNil(t *testing.T) { + // Should fall through to the default + c := &Config{ + StorageCreator: func(caURL *url.URL) (Storage, error) { + return nil, nil + }, + } + s, err := c.StorageFor("example.com") + if err != nil { + t.Fatal(err) + } + if reflect.TypeOf(s).Name() != "FileStorage" { + t.Fatalf("Unexpected storage type: %v", reflect.TypeOf(s).Name()) + } +} + +type fakeStorage string + +func (s fakeStorage) SiteExists(domain string) bool { + panic("no impl") +} + +func (s fakeStorage) LoadSite(domain string) (*SiteData, error) { + panic("no impl") +} + +func (s fakeStorage) StoreSite(domain string, data *SiteData) error { + panic("no impl") +} + +func (s fakeStorage) DeleteSite(domain string) error { + panic("no impl") +} + +func (s fakeStorage) LockRegister(domain string) (bool, error) { + panic("no impl") +} + +func (s fakeStorage) UnlockRegister(domain string) error { + panic("no impl") +} + +func (s fakeStorage) LoadUser(email string) (*UserData, error) { + panic("no impl") +} + +func (s fakeStorage) StoreUser(email string, data *UserData) error { + panic("no impl") +} + +func (s fakeStorage) MostRecentUserEmail() string { + panic("no impl") +} diff --git a/caddytls/crypto.go b/caddytls/crypto.go index 243b37f5d..04ee226c2 100644 --- a/caddytls/crypto.go +++ b/caddytls/crypto.go @@ -14,21 +14,15 @@ import ( "errors" "fmt" "io" - "io/ioutil" "math/big" "net" - "os" "time" "github.com/xenolf/lego/acme" ) -// loadPrivateKey loads a PEM-encoded ECC/RSA private key from file. -func loadPrivateKey(file string) (crypto.PrivateKey, error) { - keyBytes, err := ioutil.ReadFile(file) - if err != nil { - return nil, err - } +// loadPrivateKey loads a PEM-encoded ECC/RSA private key from an array of bytes. +func loadPrivateKey(keyBytes []byte) (crypto.PrivateKey, error) { keyBlock, _ := pem.Decode(keyBytes) switch keyBlock.Type { @@ -41,8 +35,8 @@ func loadPrivateKey(file string) (crypto.PrivateKey, error) { return nil, errors.New("unknown private key type") } -// savePrivateKey saves a PEM-encoded ECC/RSA private key to file. -func savePrivateKey(key crypto.PrivateKey, file string) error { +// savePrivateKey saves a PEM-encoded ECC/RSA private key to an array of bytes. +func savePrivateKey(key crypto.PrivateKey) ([]byte, error) { var pemType string var keyBytes []byte switch key := key.(type) { @@ -51,7 +45,7 @@ func savePrivateKey(key crypto.PrivateKey, file string) error { pemType = "EC" keyBytes, err = x509.MarshalECPrivateKey(key) if err != nil { - return err + return nil, err } case *rsa.PrivateKey: pemType = "RSA" @@ -59,13 +53,7 @@ func savePrivateKey(key crypto.PrivateKey, file string) error { } pemKey := pem.Block{Type: pemType + " PRIVATE KEY", Bytes: keyBytes} - keyOut, err := os.Create(file) - if err != nil { - return err - } - keyOut.Chmod(0600) - defer keyOut.Close() - return pem.Encode(keyOut, &pemKey) + return pem.EncodeToMemory(&pemKey), nil } // stapleOCSP staples OCSP information to cert for hostname name. diff --git a/caddytls/crypto_test.go b/caddytls/crypto_test.go index 3eca43ae2..e4697ec46 100644 --- a/caddytls/crypto_test.go +++ b/caddytls/crypto_test.go @@ -9,42 +9,24 @@ import ( "crypto/rsa" "crypto/tls" "crypto/x509" - "os" - "runtime" "testing" "time" ) func TestSaveAndLoadRSAPrivateKey(t *testing.T) { - keyFile := "test.key" - defer os.Remove(keyFile) - privateKey, err := rsa.GenerateKey(rand.Reader, 128) // make tests faster; small key size OK for testing if err != nil { t.Fatal(err) } // test save - err = savePrivateKey(privateKey, keyFile) + savedBytes, err := savePrivateKey(privateKey) if err != nil { t.Fatal("error saving private key:", err) } - // it doesn't make sense to test file permission on windows - if runtime.GOOS != "windows" { - // get info of the key file - info, err := os.Stat(keyFile) - if err != nil { - t.Fatal("error stating private key:", err) - } - // verify permission of key file is correct - if info.Mode().Perm() != 0600 { - t.Error("Expected key file to have permission 0600, but it wasn't") - } - } - // test load - loadedKey, err := loadPrivateKey(keyFile) + loadedKey, err := loadPrivateKey(savedBytes) if err != nil { t.Error("error loading private key:", err) } @@ -56,35 +38,19 @@ func TestSaveAndLoadRSAPrivateKey(t *testing.T) { } func TestSaveAndLoadECCPrivateKey(t *testing.T) { - keyFile := "test.key" - defer os.Remove(keyFile) - privateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) if err != nil { t.Fatal(err) } // test save - err = savePrivateKey(privateKey, keyFile) + savedBytes, err := savePrivateKey(privateKey) if err != nil { t.Fatal("error saving private key:", err) } - // it doesn't make sense to test file permission on windows - if runtime.GOOS != "windows" { - // get info of the key file - info, err := os.Stat(keyFile) - if err != nil { - t.Fatal("error stating private key:", err) - } - // verify permission of key file is correct - if info.Mode().Perm() != 0600 { - t.Error("Expected key file to have permission 0600, but it wasn't") - } - } - // test load - loadedKey, err := loadPrivateKey(keyFile) + loadedKey, err := loadPrivateKey(savedBytes) if err != nil { t.Error("error loading private key:", err) } diff --git a/caddytls/filestorage.go b/caddytls/filestorage.go new file mode 100644 index 000000000..32d001fae --- /dev/null +++ b/caddytls/filestorage.go @@ -0,0 +1,239 @@ +package caddytls + +import ( + "github.com/mholt/caddy" + "io/ioutil" + "net/url" + "os" + "path/filepath" + "strings" +) + +// storageBasePath is the root path in which all TLS/ACME assets are +// stored. Do not change this value during the lifetime of the program. +var storageBasePath = filepath.Join(caddy.AssetsPath(), "acme") + +// FileStorageCreator creates a new Storage instance backed by the local +// disk. The resulting Storage instance is guaranteed to be non-nil if +// there is no error. This can be used by "middleware" implementations that +// may want to proxy the disk storage. +func FileStorageCreator(caURL *url.URL) (Storage, error) { + return FileStorage(filepath.Join(storageBasePath, caURL.Host)), nil +} + +// FileStorage is a root directory and facilitates forming file paths derived +// from it. It is used to get file paths in a consistent, cross- platform way +// for persisting ACME assets on the file system. +type FileStorage string + +// sites gets the directory that stores site certificate and keys. +func (s FileStorage) sites() string { + return filepath.Join(string(s), "sites") +} + +// site returns the path to the folder containing assets for domain. +func (s FileStorage) site(domain string) string { + domain = strings.ToLower(domain) + return filepath.Join(s.sites(), domain) +} + +// siteCertFile returns the path to the certificate file for domain. +func (s FileStorage) siteCertFile(domain string) string { + domain = strings.ToLower(domain) + return filepath.Join(s.site(domain), domain+".crt") +} + +// siteKeyFile returns the path to domain's private key file. +func (s FileStorage) siteKeyFile(domain string) string { + domain = strings.ToLower(domain) + return filepath.Join(s.site(domain), domain+".key") +} + +// siteMetaFile returns the path to the domain's asset metadata file. +func (s FileStorage) siteMetaFile(domain string) string { + domain = strings.ToLower(domain) + return filepath.Join(s.site(domain), domain+".json") +} + +// users gets the directory that stores account folders. +func (s FileStorage) users() string { + return filepath.Join(string(s), "users") +} + +// user gets the account folder for the user with email +func (s FileStorage) user(email string) string { + if email == "" { + email = emptyEmail + } + email = strings.ToLower(email) + return filepath.Join(s.users(), email) +} + +// emailUsername returns the username portion of an email address (part before +// '@') or the original input if it can't find the "@" symbol. +func emailUsername(email string) string { + at := strings.Index(email, "@") + if at == -1 { + return email + } else if at == 0 { + return email[1:] + } + return email[:at] +} + +// userRegFile gets the path to the registration file for the user with the +// given email address. +func (s FileStorage) userRegFile(email string) string { + if email == "" { + email = emptyEmail + } + email = strings.ToLower(email) + fileName := emailUsername(email) + if fileName == "" { + fileName = "registration" + } + return filepath.Join(s.user(email), fileName+".json") +} + +// userKeyFile gets the path to the private key file for the user with the +// given email address. +func (s FileStorage) userKeyFile(email string) string { + if email == "" { + email = emptyEmail + } + email = strings.ToLower(email) + fileName := emailUsername(email) + if fileName == "" { + fileName = "private" + } + return filepath.Join(s.user(email), fileName+".key") +} + +// readFile abstracts a simple ioutil.ReadFile, making sure to return an +// ErrStorageNotFound instance when the file is not found. +func (s FileStorage) readFile(file string) ([]byte, error) { + byts, err := ioutil.ReadFile(file) + if os.IsNotExist(err) { + return nil, ErrStorageNotFound + } + return byts, err +} + +// SiteExists implements Storage.SiteExists by checking for the presence of +// cert and key files. +func (s FileStorage) SiteExists(domain string) bool { + _, err := os.Stat(s.siteCertFile(domain)) + if err != nil { + return false + } + _, err = os.Stat(s.siteKeyFile(domain)) + if err != nil { + return false + } + return true +} + +// LoadSite implements Storage.LoadSite by loading it from disk. If it is not +// present, the ErrStorageNotFound error instance is returned. +func (s FileStorage) LoadSite(domain string) (*SiteData, error) { + var err error + siteData := new(SiteData) + siteData.Cert, err = s.readFile(s.siteCertFile(domain)) + if err == nil { + siteData.Key, err = s.readFile(s.siteKeyFile(domain)) + } + if err == nil { + siteData.Meta, err = s.readFile(s.siteMetaFile(domain)) + } + return siteData, err +} + +// StoreSite implements Storage.StoreSite by writing it to disk. The base +// directories needed for the file are automatically created as needed. +func (s FileStorage) StoreSite(domain string, data *SiteData) error { + err := os.MkdirAll(s.site(domain), 0700) + if err != nil { + return err + } + err = ioutil.WriteFile(s.siteCertFile(domain), data.Cert, 0600) + if err == nil { + err = ioutil.WriteFile(s.siteKeyFile(domain), data.Key, 0600) + } + if err == nil { + err = ioutil.WriteFile(s.siteMetaFile(domain), data.Meta, 0600) + } + return err +} + +// DeleteSite implements Storage.DeleteSite by deleting just the cert from +// disk. If it is not present, the ErrStorageNotFound error instance is +// returned. +func (s FileStorage) DeleteSite(domain string) error { + err := os.Remove(s.siteCertFile(domain)) + if os.IsNotExist(err) { + return ErrStorageNotFound + } + return err +} + +// LockRegister implements Storage.LockRegister by just returning true because +// it is not a multi-server storage implementation. +func (s FileStorage) LockRegister(domain string) (bool, error) { + return true, nil +} + +// UnlockRegister implements Storage.UnlockRegister as a no-op because it is +// not a multi-server storage implementation. +func (s FileStorage) UnlockRegister(domain string) error { + return nil +} + +// LoadUser implements Storage.LoadUser by loading it from disk. If it is not +// present, the ErrStorageNotFound error instance is returned. +func (s FileStorage) LoadUser(email string) (*UserData, error) { + var err error + userData := new(UserData) + userData.Reg, err = s.readFile(s.userRegFile(email)) + if err == nil { + userData.Key, err = s.readFile(s.userKeyFile(email)) + } + return userData, err +} + +// StoreUser implements Storage.StoreUser by writing it to disk. The base +// directories needed for the file are automatically created as needed. +func (s FileStorage) StoreUser(email string, data *UserData) error { + err := os.MkdirAll(s.user(email), 0700) + if err != nil { + return err + } + err = ioutil.WriteFile(s.userRegFile(email), data.Reg, 0600) + if err == nil { + err = ioutil.WriteFile(s.userKeyFile(email), data.Key, 0600) + } + return err +} + +// MostRecentUserEmail implements Storage.MostRecentUserEmail by finding the +// most recently written sub directory in the users' directory. It is named +// after the email address. This corresponds to the most recent call to +// StoreUser. +func (s FileStorage) MostRecentUserEmail() string { + userDirs, err := ioutil.ReadDir(s.users()) + if err != nil { + return "" + } + var mostRecent os.FileInfo + for _, dir := range userDirs { + if !dir.IsDir() { + continue + } + if mostRecent == nil || dir.ModTime().After(mostRecent.ModTime()) { + mostRecent = dir + } + } + if mostRecent != nil { + return mostRecent.Name() + } + return "" +} diff --git a/caddytls/filestorage_test.go b/caddytls/filestorage_test.go new file mode 100644 index 000000000..b77fd97dd --- /dev/null +++ b/caddytls/filestorage_test.go @@ -0,0 +1,6 @@ +package caddytls + +// *********************************** NOTE ******************************** +// Due to circular package dependencies with the storagetest sub package and +// the fact that we want to use that harness to test file storage, the tests +// for file storage are done in the storagetest package. diff --git a/caddytls/maintain.go b/caddytls/maintain.go index 96514ac24..e747d8588 100644 --- a/caddytls/maintain.go +++ b/caddytls/maintain.go @@ -93,7 +93,10 @@ func RenewManagedCertificates(allowPrompts bool) (err error) { continue } - // this works well because managed certs are only associated with one name per config + // This works well because managed certs are only associated with one name per config. + // Note, the renewal inside here may not actually occur and no error will be returned + // due to renewal lock (i.e. because a renewal is already happening). This lack of + // error is by intention to force cache invalidation as though it has renewed. err := cert.Config.RenewCert(allowPrompts) if err != nil { diff --git a/caddytls/storage.go b/caddytls/storage.go index 1a00a9de7..978eb5013 100644 --- a/caddytls/storage.go +++ b/caddytls/storage.go @@ -1,134 +1,105 @@ package caddytls import ( - "fmt" + "errors" "net/url" - "path/filepath" - "strings" - - "github.com/mholt/caddy" ) -// StorageFor gets the storage value associated with the -// caURL, which should be unique for every different -// ACME CA. -func StorageFor(caURL string) (Storage, error) { - if caURL == "" { - caURL = DefaultCAUrl - } - if caURL == "" { - return "", fmt.Errorf("cannot create storage without CA URL") - } - caURL = strings.ToLower(caURL) +// ErrStorageNotFound is returned by Storage implementations when data is +// expected to be present but is not. +var ErrStorageNotFound = errors.New("data not found") - // scheme required or host will be parsed as path (as of Go 1.6) - if !strings.Contains(caURL, "://") { - caURL = "https://" + caURL - } +// StorageCreator is a function type that is used in the Config to instantiate +// a new Storage instance. This function can return a nil Storage even without +// an error. +type StorageCreator func(caURL *url.URL) (Storage, error) - u, err := url.Parse(caURL) - if err != nil { - return "", fmt.Errorf("%s: unable to parse CA URL: %v", caURL, err) - } - - if u.Host == "" { - return "", fmt.Errorf("%s: no host in CA URL", caURL) - } - - return Storage(filepath.Join(storageBasePath, u.Host)), nil +// SiteData contains persisted items pertaining to an individual site. +type SiteData struct { + // Cert is the public cert byte array. + Cert []byte + // Key is the private key byte array. + Key []byte + // Meta is metadata about the site used by Caddy. + Meta []byte } -// Storage is a root directory and facilitates -// forming file paths derived from it. It is used -// to get file paths in a consistent, cross- -// platform way for persisting ACME assets. -// on the file system. -type Storage string - -// Sites gets the directory that stores site certificate and keys. -func (s Storage) Sites() string { - return filepath.Join(string(s), "sites") +// UserData contains persisted items pertaining to a user. +type UserData struct { + // Reg is the user registration byte array. + Reg []byte + // Key is the user key byte array. + Key []byte } -// Site returns the path to the folder containing assets for domain. -func (s Storage) Site(domain string) string { - domain = strings.ToLower(domain) - return filepath.Join(s.Sites(), domain) -} +// Storage is an interface abstracting all storage used by the Caddy's TLS +// subsystem. Implementations of this interface store site data along with +// user data. +type Storage interface { -// SiteCertFile returns the path to the certificate file for domain. -func (s Storage) SiteCertFile(domain string) string { - domain = strings.ToLower(domain) - return filepath.Join(s.Site(domain), domain+".crt") -} + // SiteDataExists returns true if this site info exists in storage. + // Site data is considered present when StoreSite has been called + // successfully (without DeleteSite having been called of course). + SiteExists(domain string) bool -// SiteKeyFile returns the path to domain's private key file. -func (s Storage) SiteKeyFile(domain string) string { - domain = strings.ToLower(domain) - return filepath.Join(s.Site(domain), domain+".key") -} + // LoadSite obtains the site data from storage for the given domain and + // returns. If data for the domain does not exist, the + // ErrStorageNotFound error instance is returned. For multi-server + // storage, care should be taken to make this load atomic to prevent + // race conditions that happen with multiple data loads. + LoadSite(domain string) (*SiteData, error) -// SiteMetaFile returns the path to the domain's asset metadata file. -func (s Storage) SiteMetaFile(domain string) string { - domain = strings.ToLower(domain) - return filepath.Join(s.Site(domain), domain+".json") -} + // StoreSite persists the given site data for the given domain in + // storage. For multi-server storage, care should be taken to make this + // call atomic to prevent half-written data on failure of an internal + // intermediate storage step. Implementers can trust that at runtime + // this function will only be invoked after LockRegister and before + // UnlockRegister of the same domain. + StoreSite(domain string, data *SiteData) error -// Users gets the directory that stores account folders. -func (s Storage) Users() string { - return filepath.Join(string(s), "users") -} + // DeleteSite deletes the site for the given domain from storage. + // Multi-server implementations should attempt to make this atomic. If + // the site does not exist, the ErrStorageNotFound error instance is + // returned. + DeleteSite(domain string) error -// User gets the account folder for the user with email. -func (s Storage) User(email string) string { - if email == "" { - email = emptyEmail - } - email = strings.ToLower(email) - return filepath.Join(s.Users(), email) -} + // LockRegister is called before Caddy attempts to obtain or renew a + // certificate. This function is used as a mutex/semaphore for making + // sure something else isn't already attempting obtain/renew. It should + // return true (without error) if the lock is successfully obtained + // meaning nothing else is attempting renewal. It should return false + // (without error) if this domain is already locked by something else + // attempting renewal. As a general rule, if this isn't multi-server + // shared storage, this should always return true. To prevent deadlocks + // for multi-server storage, all internal implementations should put a + // reasonable expiration on this lock in case UnlockRegister is unable to + // be called due to system crash. Errors should only be returned in + // exceptional cases. Any error will prevent renewal. + LockRegister(domain string) (bool, error) -// UserRegFile gets the path to the registration file for -// the user with the given email address. -func (s Storage) UserRegFile(email string) string { - if email == "" { - email = emptyEmail - } - email = strings.ToLower(email) - fileName := emailUsername(email) - if fileName == "" { - fileName = "registration" - } - return filepath.Join(s.User(email), fileName+".json") -} + // UnlockRegister is called after Caddy has attempted to obtain or renew + // a certificate, regardless of whether it was successful. If + // LockRegister essentially just returns true because this is not + // multi-server storage, this can be a no-op. Otherwise this should + // attempt to unlock the lock obtained in this process by LockRegister. + // If no lock exists, the implementation should not return an error. An + // error is only for exceptional cases. + UnlockRegister(domain string) error -// UserKeyFile gets the path to the private key file for -// the user with the given email address. -func (s Storage) UserKeyFile(email string) string { - if email == "" { - email = emptyEmail - } - email = strings.ToLower(email) - fileName := emailUsername(email) - if fileName == "" { - fileName = "private" - } - return filepath.Join(s.User(email), fileName+".key") -} + // LoadUser obtains user data from storage for the given email and + // returns it. If data for the email does not exist, the + // ErrStorageNotFound error instance is returned. Multi-server + // implementations should take care to make this operation atomic for + // all loaded data items. + LoadUser(email string) (*UserData, error) -// emailUsername returns the username portion of an -// email address (part before '@') or the original -// input if it can't find the "@" symbol. -func emailUsername(email string) string { - at := strings.Index(email, "@") - if at == -1 { - return email - } else if at == 0 { - return email[1:] - } - return email[:at] -} + // StoreUser persists the given user data for the given email in + // storage. Multi-server implementations should take care to make this + // operation atomic for all stored data items. + StoreUser(email string, data *UserData) error -// storageBasePath is the root path in which all TLS/ACME assets are -// stored. Do not change this value during the lifetime of the program. -var storageBasePath = filepath.Join(caddy.AssetsPath(), "acme") + // MostRecentUserEmail provides the most recently used email parameter + // in StoreUser. The result is an empty string if there are no + // persisted users in storage. + MostRecentUserEmail() string +} diff --git a/caddytls/storage_test.go b/caddytls/storage_test.go deleted file mode 100644 index e9175af96..000000000 --- a/caddytls/storage_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package caddytls - -import ( - "path/filepath" - "testing" -) - -func TestStorageFor(t *testing.T) { - // first try without DefaultCAUrl set - DefaultCAUrl = "" - _, err := StorageFor("") - if err == nil { - t.Errorf("Without a default CA, expected error, but didn't get one") - } - st, err := StorageFor("https://example.com/foo") - if err != nil { - t.Errorf("Without a default CA but given input, expected no error, but got: %v", err) - } - if string(st) != filepath.Join(storageBasePath, "example.com") { - t.Errorf("Without a default CA but given input, expected '%s' not '%s'", "example.com", st) - } - - // try with the DefaultCAUrl set - DefaultCAUrl = "https://defaultCA/directory" - for i, test := range []struct { - input, expect string - shouldErr bool - }{ - {"https://acme-staging.api.letsencrypt.org/directory", "acme-staging.api.letsencrypt.org", false}, - {"https://foo/boo?bar=q", "foo", false}, - {"http://foo", "foo", false}, - {"", "defaultca", false}, - {"https://FooBar/asdf", "foobar", false}, - {"noscheme/path", "noscheme", false}, - {"/nohost", "", true}, - {"https:///nohost", "", true}, - {"FooBar", "foobar", false}, - } { - st, err := StorageFor(test.input) - if err == nil && test.shouldErr { - t.Errorf("Test %d: Expected an error, but didn't get one", i) - } else if err != nil && !test.shouldErr { - t.Errorf("Test %d: Expected no errors, but got: %v", i, err) - } - want := filepath.Join(storageBasePath, test.expect) - if test.shouldErr { - want = "" - } - if string(st) != want { - t.Errorf("Test %d: Expected '%s' but got '%s'", i, want, string(st)) - } - } -} - -func TestStorage(t *testing.T) { - storage := Storage("./le_test") - - if expected, actual := filepath.Join("le_test", "sites"), storage.Sites(); actual != expected { - t.Errorf("Expected Sites() to return '%s' but got '%s'", expected, actual) - } - if expected, actual := filepath.Join("le_test", "sites", "test.com"), storage.Site("Test.com"); actual != expected { - t.Errorf("Expected Site() to return '%s' but got '%s'", expected, actual) - } - if expected, actual := filepath.Join("le_test", "sites", "test.com", "test.com.crt"), storage.SiteCertFile("Test.com"); actual != expected { - t.Errorf("Expected SiteCertFile() to return '%s' but got '%s'", expected, actual) - } - if expected, actual := filepath.Join("le_test", "sites", "test.com", "test.com.key"), storage.SiteKeyFile("test.com"); actual != expected { - t.Errorf("Expected SiteKeyFile() to return '%s' but got '%s'", expected, actual) - } - if expected, actual := filepath.Join("le_test", "sites", "test.com", "test.com.json"), storage.SiteMetaFile("TEST.COM"); actual != expected { - t.Errorf("Expected SiteMetaFile() to return '%s' but got '%s'", expected, actual) - } - if expected, actual := filepath.Join("le_test", "users"), storage.Users(); actual != expected { - t.Errorf("Expected Users() to return '%s' but got '%s'", expected, actual) - } - if expected, actual := filepath.Join("le_test", "users", "me@example.com"), storage.User("Me@example.com"); actual != expected { - t.Errorf("Expected User() to return '%s' but got '%s'", expected, actual) - } - if expected, actual := filepath.Join("le_test", "users", "me@example.com", "me.json"), storage.UserRegFile("ME@EXAMPLE.COM"); actual != expected { - t.Errorf("Expected UserRegFile() to return '%s' but got '%s'", expected, actual) - } - if expected, actual := filepath.Join("le_test", "users", "me@example.com", "me.key"), storage.UserKeyFile("me@example.com"); actual != expected { - t.Errorf("Expected UserKeyFile() to return '%s' but got '%s'", expected, actual) - } - - // Test with empty emails - if expected, actual := filepath.Join("le_test", "users", emptyEmail), storage.User(emptyEmail); actual != expected { - t.Errorf("Expected User(\"\") to return '%s' but got '%s'", expected, actual) - } - if expected, actual := filepath.Join("le_test", "users", emptyEmail, emptyEmail+".json"), storage.UserRegFile(""); actual != expected { - t.Errorf("Expected UserRegFile(\"\") to return '%s' but got '%s'", expected, actual) - } - if expected, actual := filepath.Join("le_test", "users", emptyEmail, emptyEmail+".key"), storage.UserKeyFile(""); actual != expected { - t.Errorf("Expected UserKeyFile(\"\") to return '%s' but got '%s'", expected, actual) - } -} - -func TestEmailUsername(t *testing.T) { - for i, test := range []struct { - input, expect string - }{ - { - input: "username@example.com", - expect: "username", - }, - { - input: "plus+addressing@example.com", - expect: "plus+addressing", - }, - { - input: "me+plus-addressing@example.com", - expect: "me+plus-addressing", - }, - { - input: "not-an-email", - expect: "not-an-email", - }, - { - input: "@foobar.com", - expect: "foobar.com", - }, - { - input: emptyEmail, - expect: emptyEmail, - }, - { - input: "", - expect: "", - }, - } { - if actual := emailUsername(test.input); actual != test.expect { - t.Errorf("Test %d: Expected username to be '%s' but was '%s'", i, test.expect, actual) - } - } -} diff --git a/caddytls/storagetest/memorystorage.go b/caddytls/storagetest/memorystorage.go new file mode 100644 index 000000000..cc1a48b5f --- /dev/null +++ b/caddytls/storagetest/memorystorage.go @@ -0,0 +1,132 @@ +package storagetest + +import ( + "github.com/mholt/caddy/caddytls" + "net/url" + "sync" +) + +// memoryMutex is a mutex used to control access to memoryStoragesByCAURL. +var memoryMutex sync.Mutex + +// memoryStoragesByCAURL is a map keyed by a CA URL string with values of +// instantiated memory stores. Do not access this directly, it is used by +// InMemoryStorageCreator. +var memoryStoragesByCAURL = make(map[string]*InMemoryStorage) + +// InMemoryStorageCreator is a caddytls.Storage.StorageCreator to create +// InMemoryStorage instances for testing. +func InMemoryStorageCreator(caURL *url.URL) (caddytls.Storage, error) { + urlStr := caURL.String() + memoryMutex.Lock() + defer memoryMutex.Unlock() + storage := memoryStoragesByCAURL[urlStr] + if storage == nil { + storage = NewInMemoryStorage() + memoryStoragesByCAURL[urlStr] = storage + } + return storage, nil +} + +// InMemoryStorage is a caddytls.Storage implementation for use in testing. +// It simply stores information in runtime memory. +type InMemoryStorage struct { + // Sites are exposed for testing purposes. + Sites map[string]*caddytls.SiteData + // Users are exposed for testing purposes. + Users map[string]*caddytls.UserData + // LastUserEmail is exposed for testing purposes. + LastUserEmail string +} + +// NewInMemoryStorage constructs an InMemoryStorage instance. For use with +// caddytls, the InMemoryStorageCreator should be used instead. +func NewInMemoryStorage() *InMemoryStorage { + return &InMemoryStorage{ + Sites: make(map[string]*caddytls.SiteData), + Users: make(map[string]*caddytls.UserData), + } +} + +// SiteExists implements caddytls.Storage.SiteExists in memory. +func (s *InMemoryStorage) SiteExists(domain string) bool { + _, siteExists := s.Sites[domain] + return siteExists +} + +// Clear completely clears all values associated with this storage. +func (s *InMemoryStorage) Clear() { + s.Sites = make(map[string]*caddytls.SiteData) + s.Users = make(map[string]*caddytls.UserData) + s.LastUserEmail = "" +} + +// LoadSite implements caddytls.Storage.LoadSite in memory. +func (s *InMemoryStorage) LoadSite(domain string) (*caddytls.SiteData, error) { + siteData, ok := s.Sites[domain] + if !ok { + return nil, caddytls.ErrStorageNotFound + } + return siteData, nil +} + +func copyBytes(from []byte) []byte { + copiedBytes := make([]byte, len(from)) + copy(copiedBytes, from) + return copiedBytes +} + +// StoreSite implements caddytls.Storage.StoreSite in memory. +func (s *InMemoryStorage) StoreSite(domain string, data *caddytls.SiteData) error { + copiedData := new(caddytls.SiteData) + copiedData.Cert = copyBytes(data.Cert) + copiedData.Key = copyBytes(data.Key) + copiedData.Meta = copyBytes(data.Meta) + s.Sites[domain] = copiedData + return nil +} + +// DeleteSite implements caddytls.Storage.DeleteSite in memory. +func (s *InMemoryStorage) DeleteSite(domain string) error { + if _, ok := s.Sites[domain]; !ok { + return caddytls.ErrStorageNotFound + } + delete(s.Sites, domain) + return nil +} + +// LockRegister implements Storage.LockRegister by just returning true because +// it is not a multi-server storage implementation. +func (s *InMemoryStorage) LockRegister(domain string) (bool, error) { + return true, nil +} + +// UnlockRegister implements Storage.UnlockRegister as a no-op because it is +// not a multi-server storage implementation. +func (s *InMemoryStorage) UnlockRegister(domain string) error { + return nil +} + +// LoadUser implements caddytls.Storage.LoadUser in memory. +func (s *InMemoryStorage) LoadUser(email string) (*caddytls.UserData, error) { + userData, ok := s.Users[email] + if !ok { + return nil, caddytls.ErrStorageNotFound + } + return userData, nil +} + +// StoreUser implements caddytls.Storage.StoreUser in memory. +func (s *InMemoryStorage) StoreUser(email string, data *caddytls.UserData) error { + copiedData := new(caddytls.UserData) + copiedData.Reg = copyBytes(data.Reg) + copiedData.Key = copyBytes(data.Key) + s.Users[email] = copiedData + s.LastUserEmail = email + return nil +} + +// MostRecentUserEmail implements caddytls.Storage.MostRecentUserEmail in memory. +func (s *InMemoryStorage) MostRecentUserEmail() string { + return s.LastUserEmail +} diff --git a/caddytls/storagetest/memorystorage_test.go b/caddytls/storagetest/memorystorage_test.go new file mode 100644 index 000000000..286fbb424 --- /dev/null +++ b/caddytls/storagetest/memorystorage_test.go @@ -0,0 +1,12 @@ +package storagetest + +import "testing" + +func TestMemoryStorage(t *testing.T) { + storage := NewInMemoryStorage() + storageTest := &StorageTest{ + Storage: storage, + PostTest: storage.Clear, + } + storageTest.Test(t, false) +} diff --git a/caddytls/storagetest/storagetest.go b/caddytls/storagetest/storagetest.go new file mode 100644 index 000000000..0400aded3 --- /dev/null +++ b/caddytls/storagetest/storagetest.go @@ -0,0 +1,270 @@ +// Package storagetest provides utilities to assist in testing caddytls.Storage +// implementations. +package storagetest + +import ( + "bytes" + "errors" + "fmt" + "github.com/mholt/caddy/caddytls" + "testing" +) + +// StorageTest is a test harness that contains tests to execute all exposed +// parts of a Storage implementation. +type StorageTest struct { + // Storage is the implementation to use during tests. This must be + // present. + caddytls.Storage + + // PreTest, if present, is called before every test. Any error returned + // is returned from the test and the test does not continue. + PreTest func() error + + // PostTest, if present, is executed after every test via defer which + // means it executes even on failure of the test (but not on failure of + // PreTest). + PostTest func() + + // AfterUserEmailStore, if present, is invoked during + // TestMostRecentUserEmail after each storage just in case anything + // needs to be mocked. + AfterUserEmailStore func(email string) error +} + +// TestFunc holds information about a test. +type TestFunc struct { + // Name is the friendly name of the test. + Name string + + // Fn is the function that is invoked for the test. + Fn func() error +} + +// runPreTest runs the PreTest function if present. +func (s *StorageTest) runPreTest() error { + if s.PreTest != nil { + return s.PreTest() + } + return nil +} + +// runPostTest runs the PostTest function if present. +func (s *StorageTest) runPostTest() { + if s.PostTest != nil { + s.PostTest() + } +} + +// AllFuncs returns all test functions that are part of this harness. +func (s *StorageTest) AllFuncs() []TestFunc { + return []TestFunc{ + {"TestSiteInfoExists", s.TestSiteExists}, + {"TestSite", s.TestSite}, + {"TestUser", s.TestUser}, + {"TestMostRecentUserEmail", s.TestMostRecentUserEmail}, + } +} + +// Test executes the entire harness using the testing package. Failures are +// reported via T.Fatal. If eagerFail is true, the first failure causes all +// testing to stop immediately. +func (s *StorageTest) Test(t *testing.T, eagerFail bool) { + if errs := s.TestAll(eagerFail); len(errs) > 0 { + ifaces := make([]interface{}, len(errs)) + for i, err := range errs { + ifaces[i] = err + } + t.Fatal(ifaces...) + } +} + +// TestAll executes the entire harness and returns the results as an array of +// errors. If eagerFail is true, the first failure causes all testing to stop +// immediately. +func (s *StorageTest) TestAll(eagerFail bool) (errs []error) { + for _, fn := range s.AllFuncs() { + if err := fn.Fn(); err != nil { + errs = append(errs, fmt.Errorf("%v failed: %v", fn.Name, err)) + if eagerFail { + return + } + } + } + return +} + +var simpleSiteData = &caddytls.SiteData{ + Cert: []byte("foo"), + Key: []byte("bar"), + Meta: []byte("baz"), +} +var simpleSiteDataAlt = &caddytls.SiteData{ + Cert: []byte("qux"), + Key: []byte("quux"), + Meta: []byte("corge"), +} + +// TestSiteExists tests Storage.SiteExists. +func (s *StorageTest) TestSiteExists() error { + if err := s.runPreTest(); err != nil { + return err + } + defer s.runPostTest() + + // Should not exist at first + if s.SiteExists("example.com") { + return errors.New("Site should not exist") + } + + // Should exist after we store it + if err := s.StoreSite("example.com", simpleSiteData); err != nil { + return err + } + if !s.SiteExists("example.com") { + return errors.New("Expected site to exist") + } + + // Site should no longer exist after we delete it + if err := s.DeleteSite("example.com"); err != nil { + return err + } + if s.SiteExists("example.com") { + return errors.New("Site should not exist after delete") + } + return nil +} + +// TestSite tests Storage.LoadSite, Storage.StoreSite, and Storage.DeleteSite. +func (s *StorageTest) TestSite() error { + if err := s.runPreTest(); err != nil { + return err + } + defer s.runPostTest() + + // Should be a not-found error at first + if _, err := s.LoadSite("example.com"); err != caddytls.ErrStorageNotFound { + return fmt.Errorf("Expected ErrStorageNotFound from load, got: %v", err) + } + + // Delete should also be a not-found error at first + if err := s.DeleteSite("example.com"); err != caddytls.ErrStorageNotFound { + return fmt.Errorf("Expected ErrStorageNotFound from delete, got: %v", err) + } + + // Should store successfully and then load just fine + if err := s.StoreSite("example.com", simpleSiteData); err != nil { + return err + } + if siteData, err := s.LoadSite("example.com"); err != nil { + return err + } else if !bytes.Equal(siteData.Cert, simpleSiteData.Cert) { + return errors.New("Unexpected cert returned after store") + } else if !bytes.Equal(siteData.Key, simpleSiteData.Key) { + return errors.New("Unexpected key returned after store") + } else if !bytes.Equal(siteData.Meta, simpleSiteData.Meta) { + return errors.New("Unexpected meta returned after store") + } + + // Overwrite should work just fine + if err := s.StoreSite("example.com", simpleSiteDataAlt); err != nil { + return err + } + if siteData, err := s.LoadSite("example.com"); err != nil { + return err + } else if !bytes.Equal(siteData.Cert, simpleSiteDataAlt.Cert) { + return errors.New("Unexpected cert returned after overwrite") + } + + // It should delete fine and then not be there + if err := s.DeleteSite("example.com"); err != nil { + return err + } + if _, err := s.LoadSite("example.com"); err != caddytls.ErrStorageNotFound { + return fmt.Errorf("Expected ErrStorageNotFound after delete, got: %v", err) + } + + return nil +} + +var simpleUserData = &caddytls.UserData{ + Reg: []byte("foo"), + Key: []byte("bar"), +} +var simpleUserDataAlt = &caddytls.UserData{ + Reg: []byte("baz"), + Key: []byte("qux"), +} + +// TestUser tests Storage.LoadUser and Storage.StoreUser. +func (s *StorageTest) TestUser() error { + if err := s.runPreTest(); err != nil { + return err + } + defer s.runPostTest() + + // Should be a not-found error at first + if _, err := s.LoadUser("foo@example.com"); err != caddytls.ErrStorageNotFound { + return fmt.Errorf("Expected ErrStorageNotFound from load, got: %v", err) + } + + // Should store successfully and then load just fine + if err := s.StoreUser("foo@example.com", simpleUserData); err != nil { + return err + } + if userData, err := s.LoadUser("foo@example.com"); err != nil { + return err + } else if !bytes.Equal(userData.Reg, simpleUserData.Reg) { + return errors.New("Unexpected reg returned after store") + } else if !bytes.Equal(userData.Key, simpleUserData.Key) { + return errors.New("Unexpected key returned after store") + } + + // Overwrite should work just fine + if err := s.StoreUser("foo@example.com", simpleUserDataAlt); err != nil { + return err + } + if userData, err := s.LoadUser("foo@example.com"); err != nil { + return err + } else if !bytes.Equal(userData.Reg, simpleUserDataAlt.Reg) { + return errors.New("Unexpected reg returned after overwrite") + } + + return nil +} + +// TestMostRecentUserEmail tests Storage.MostRecentUserEmail. +func (s *StorageTest) TestMostRecentUserEmail() error { + if err := s.runPreTest(); err != nil { + return err + } + defer s.runPostTest() + + // Should be empty on first run + if e := s.MostRecentUserEmail(); e != "" { + return fmt.Errorf("Expected empty most recent user on first run, got: %v", e) + } + + // If we store user, then that one should be returned + if err := s.StoreUser("foo1@example.com", simpleUserData); err != nil { + return err + } + if s.AfterUserEmailStore != nil { + s.AfterUserEmailStore("foo1@example.com") + } + if e := s.MostRecentUserEmail(); e != "foo1@example.com" { + return fmt.Errorf("Unexpected most recent email after first store: %v", e) + } + + // If we store another user, then that one should be returned + if err := s.StoreUser("foo2@example.com", simpleUserDataAlt); err != nil { + return err + } + if s.AfterUserEmailStore != nil { + s.AfterUserEmailStore("foo2@example.com") + } + if e := s.MostRecentUserEmail(); e != "foo2@example.com" { + return fmt.Errorf("Unexpected most recent email after user key: %v", e) + } + return nil +} diff --git a/caddytls/storagetest/storagetest_test.go b/caddytls/storagetest/storagetest_test.go new file mode 100644 index 000000000..e602e4053 --- /dev/null +++ b/caddytls/storagetest/storagetest_test.go @@ -0,0 +1,39 @@ +package storagetest + +import ( + "fmt" + "github.com/mholt/caddy/caddytls" + "os" + "path/filepath" + "testing" + "time" +) + +// TestFileStorage tests the file storage set with the test harness in this +// package. +func TestFileStorage(t *testing.T) { + emailCounter := 0 + storageTest := &StorageTest{ + Storage: caddytls.FileStorage("./testdata"), + PostTest: func() { os.RemoveAll("./testdata") }, + AfterUserEmailStore: func(email string) error { + // We need to change the dir mod time to show a + // that certain dirs are newer. + emailCounter++ + fp := filepath.Join("./testdata", "users", email) + + // What we will do is subtract 10 days from today and + // then add counter * seconds to make the later + // counters newer. We accept that this isn't exactly + // how the file storage works because it only changes + // timestamps on *newly seen* users, but it achieves + // the result that the harness expects. + chTime := time.Now().AddDate(0, 0, -10).Add(time.Duration(emailCounter) * time.Second) + if err := os.Chtimes(fp, chTime, chTime); err != nil { + return fmt.Errorf("Unable to change file time for %v: %v", fp, err) + } + return nil + }, + } + storageTest.Test(t, false) +} diff --git a/caddytls/tls.go b/caddytls/tls.go index c871492ac..ace258800 100644 --- a/caddytls/tls.go +++ b/caddytls/tls.go @@ -16,9 +16,7 @@ package caddytls import ( "encoding/json" - "io/ioutil" "net" - "os" "strings" "github.com/xenolf/lego/acme" @@ -47,53 +45,21 @@ func HostQualifies(hostname string) bool { net.ParseIP(hostname) == nil } -// existingCertAndKey returns true if the hostname has -// a certificate and private key in storage already under -// the storage provided, otherwise it returns false. -func existingCertAndKey(storage Storage, hostname string) bool { - _, err := os.Stat(storage.SiteCertFile(hostname)) - if err != nil { - return false - } - _, err = os.Stat(storage.SiteKeyFile(hostname)) - if err != nil { - return false - } - return true -} - // saveCertResource saves the certificate resource to disk. This // includes the certificate file itself, the private key, and the // metadata file. func saveCertResource(storage Storage, cert acme.CertificateResource) error { - err := os.MkdirAll(storage.Site(cert.Domain), 0700) - if err != nil { - return err + // Save cert, private key, and metadata + siteData := &SiteData{ + Cert: cert.Certificate, + Key: cert.PrivateKey, } - - // Save cert - err = ioutil.WriteFile(storage.SiteCertFile(cert.Domain), cert.Certificate, 0600) - if err != nil { - return err + var err error + siteData.Meta, err = json.MarshalIndent(&cert, "", "\t") + if err == nil { + err = storage.StoreSite(cert.Domain, siteData) } - - // Save private key - err = ioutil.WriteFile(storage.SiteKeyFile(cert.Domain), cert.PrivateKey, 0600) - if err != nil { - return err - } - - // Save cert metadata - jsonBytes, err := json.MarshalIndent(&cert, "", "\t") - if err != nil { - return err - } - err = ioutil.WriteFile(storage.SiteMetaFile(cert.Domain), jsonBytes, 0600) - if err != nil { - return err - } - - return nil + return err } // Revoke revokes the certificate for host via ACME protocol. diff --git a/caddytls/tls_test.go b/caddytls/tls_test.go index c46e24947..89b22aca9 100644 --- a/caddytls/tls_test.go +++ b/caddytls/tls_test.go @@ -1,7 +1,6 @@ package caddytls import ( - "io/ioutil" "os" "testing" @@ -80,7 +79,7 @@ func TestQualifiesForManagedTLS(t *testing.T) { } func TestSaveCertResource(t *testing.T) { - storage := Storage("./le_test_save") + storage := FileStorage("./le_test_save") defer func() { err := os.RemoveAll(string(storage)) if err != nil { @@ -110,33 +109,23 @@ func TestSaveCertResource(t *testing.T) { t.Fatalf("Expected no error, got: %v", err) } - certFile, err := ioutil.ReadFile(storage.SiteCertFile(domain)) + siteData, err := storage.LoadSite(domain) if err != nil { - t.Errorf("Expected no error reading certificate file, got: %v", err) + t.Errorf("Expected no error reading site, got: %v", err) } - if string(certFile) != certContents { - t.Errorf("Expected certificate file to contain '%s', got '%s'", certContents, string(certFile)) + if string(siteData.Cert) != certContents { + t.Errorf("Expected certificate file to contain '%s', got '%s'", certContents, string(siteData.Cert)) } - - keyFile, err := ioutil.ReadFile(storage.SiteKeyFile(domain)) - if err != nil { - t.Errorf("Expected no error reading private key file, got: %v", err) + if string(siteData.Key) != keyContents { + t.Errorf("Expected private key file to contain '%s', got '%s'", keyContents, string(siteData.Key)) } - if string(keyFile) != keyContents { - t.Errorf("Expected private key file to contain '%s', got '%s'", keyContents, string(keyFile)) - } - - metaFile, err := ioutil.ReadFile(storage.SiteMetaFile(domain)) - if err != nil { - t.Errorf("Expected no error reading meta file, got: %v", err) - } - if string(metaFile) != metaContents { - t.Errorf("Expected meta file to contain '%s', got '%s'", metaContents, string(metaFile)) + if string(siteData.Meta) != metaContents { + t.Errorf("Expected meta file to contain '%s', got '%s'", metaContents, string(siteData.Meta)) } } func TestExistingCertAndKey(t *testing.T) { - storage := Storage("./le_test_existing") + storage := FileStorage("./le_test_existing") defer func() { err := os.RemoveAll(string(storage)) if err != nil { @@ -146,7 +135,7 @@ func TestExistingCertAndKey(t *testing.T) { domain := "example.com" - if existingCertAndKey(storage, domain) { + if storage.SiteExists(domain) { t.Errorf("Did NOT expect %v to have existing cert or key, but it did", domain) } @@ -159,7 +148,7 @@ func TestExistingCertAndKey(t *testing.T) { t.Fatalf("Expected no error, got: %v", err) } - if !existingCertAndKey(storage, domain) { + if !storage.SiteExists(domain) { t.Errorf("Expected %v to have existing cert and key, but it did NOT", domain) } } diff --git a/caddytls/user.go b/caddytls/user.go index d10680b91..4ea03daaa 100644 --- a/caddytls/user.go +++ b/caddytls/user.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "os" "strings" @@ -67,20 +66,9 @@ func getEmail(storage Storage, userPresent bool) string { leEmail := DefaultEmail if leEmail == "" { // Then try to get most recent user email - userDirs, err := ioutil.ReadDir(storage.Users()) - if err == nil { - var mostRecent os.FileInfo - for _, dir := range userDirs { - if !dir.IsDir() { - continue - } - if mostRecent == nil || dir.ModTime().After(mostRecent.ModTime()) { - leEmail = dir.Name() - DefaultEmail = leEmail // save for next time - mostRecent = dir - } - } - } + leEmail = storage.MostRecentUserEmail() + // Save for next time + DefaultEmail = leEmail } if leEmail == "" && userPresent { // Alas, we must bother the user and ask for an email address; @@ -112,25 +100,24 @@ func getEmail(storage Storage, userPresent bool) string { func getUser(storage Storage, email string) (User, error) { var user User - // open user file - regFile, err := os.Open(storage.UserRegFile(email)) + // open user reg + userData, err := storage.LoadUser(email) if err != nil { - if os.IsNotExist(err) { + if err == ErrStorageNotFound { // create a new user return newUser(email) } return user, err } - defer regFile.Close() // load user information - err = json.NewDecoder(regFile).Decode(&user) + err = json.Unmarshal(userData.Reg, &user) if err != nil { return user, err } // load their private key - user.key, err = loadPrivateKey(storage.UserKeyFile(email)) + user.key, err = loadPrivateKey(userData.Key) if err != nil { return user, err } @@ -144,25 +131,17 @@ func getUser(storage Storage, email string) (User, error) { // wherein the user should be saved. It should be the storage // for the CA with which user has an account. func saveUser(storage Storage, user User) error { - // make user account folder - err := os.MkdirAll(storage.User(user.Email), 0700) - if err != nil { - return err + // Save the private key and registration + userData := new(UserData) + var err error + userData.Key, err = savePrivateKey(user.key) + if err == nil { + userData.Reg, err = json.MarshalIndent(&user, "", "\t") } - - // save private key file - err = savePrivateKey(user.key, storage.UserKeyFile(user.Email)) - if err != nil { - return err + if err == nil { + err = storage.StoreUser(user.Email, userData) } - - // save registration file - jsonBytes, err := json.MarshalIndent(&user, "", "\t") - if err != nil { - return err - } - - return ioutil.WriteFile(storage.UserRegFile(user.Email), jsonBytes, 0600) + return err } // promptUserAgreement prompts the user to agree to the agreement diff --git a/caddytls/user_test.go b/caddytls/user_test.go index 67f730827..6965d149f 100644 --- a/caddytls/user_test.go +++ b/caddytls/user_test.go @@ -5,15 +5,17 @@ import ( "crypto/rand" "crypto/rsa" "io" - "os" "strings" "testing" "time" "github.com/xenolf/lego/acme" + "os" ) func TestUser(t *testing.T) { + defer testStorage.clean() + privateKey, err := rsa.GenerateKey(rand.Reader, 128) if err != nil { t.Fatalf("Could not generate test private key: %v", err) @@ -53,7 +55,7 @@ func TestNewUser(t *testing.T) { } func TestSaveUser(t *testing.T) { - defer os.RemoveAll(string(testStorage)) + defer testStorage.clean() email := "me@foobar.com" user, err := newUser(email) @@ -65,18 +67,14 @@ func TestSaveUser(t *testing.T) { if err != nil { t.Fatalf("Error saving user: %v", err) } - _, err = os.Stat(testStorage.UserRegFile(email)) + _, err = testStorage.LoadUser(email) if err != nil { - t.Errorf("Cannot access user registration file, error: %v", err) - } - _, err = os.Stat(testStorage.UserKeyFile(email)) - if err != nil { - t.Errorf("Cannot access user private key file, error: %v", err) + t.Errorf("Cannot access user data, error: %v", err) } } func TestGetUserDoesNotAlreadyExist(t *testing.T) { - defer os.RemoveAll(string(testStorage)) + defer testStorage.clean() user, err := getUser(testStorage, "user_does_not_exist@foobar.com") if err != nil { @@ -89,7 +87,7 @@ func TestGetUserDoesNotAlreadyExist(t *testing.T) { } func TestGetUserAlreadyExists(t *testing.T) { - defer os.RemoveAll(string(testStorage)) + defer testStorage.clean() email := "me@foobar.com" @@ -128,7 +126,7 @@ func TestGetEmail(t *testing.T) { os.Stdout = nil defer func() { os.Stdout = origStdout }() - defer os.RemoveAll(string(testStorage)) + defer testStorage.clean() DefaultEmail = "test2@foo.com" // Test1: Use default email from flag (or user previously typing it) @@ -166,12 +164,12 @@ func TestGetEmail(t *testing.T) { } // Change modified time so they're all different, so the test becomes deterministic - f, err := os.Stat(testStorage.User(eml)) + f, err := os.Stat(testStorage.user(eml)) if err != nil { t.Fatalf("Could not access user folder for '%s': %v", eml, err) } chTime := f.ModTime().Add(-(time.Duration(i) * time.Second)) - if err := os.Chtimes(testStorage.User(eml), chTime, chTime); err != nil { + if err := os.Chtimes(testStorage.user(eml), chTime, chTime); err != nil { t.Fatalf("Could not change user folder mod time for '%s': %v", eml, err) } } @@ -181,4 +179,8 @@ func TestGetEmail(t *testing.T) { } } -var testStorage = Storage("./testdata") +var testStorage = FileStorage("./testdata") + +func (s FileStorage) clean() error { + return os.RemoveAll(string(s)) +}