diff --git a/.travis.yml b/.travis.yml index cb760ae9..e2a018e2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ language: go go: - 1.13.x env: - - GO111MODULE=on + - GO111MODULE=on IN_TRAVIS_CI=yes notifications: slack: secure: aEvhLbhujaGaKSrOokiG3//PaVHTIrc3fBpoRbCRqfZpyq6WREoapJJhF+tIpWWOwaC9GmChbD6aHo/jMUgwKXVyPSaNjiEL87YzUUpL8B2zslNp1rgfTg/LrzthOx3Q1TYwpaAl3to0fuHUVFX4yMeC2vuThq7WSXgMMxFCtbc= diff --git a/api/server/acme/acme.go b/api/server/acme/acme.go index cecf49e0..f9df8962 100644 --- a/api/server/acme/acme.go +++ b/api/server/acme/acme.go @@ -4,8 +4,6 @@ package acme import ( "errors" "net" - - "github.com/go-acme/lego/v3/challenge" ) var ( @@ -24,62 +22,3 @@ const ( LetsEncryptStagingCA = "https://acme-staging-v02.api.letsencrypt.org/directory" LetsEncryptProductionCA = "https://acme-v02.api.letsencrypt.org/directory" ) - -// Option (or Options) are passed to New() to configure providers -type Option func(o *Options) - -// Options represents various options you can present to ACME providers -type Options struct { - // AcceptTLS must be set to true to indicate that you have read your - // provider's terms of service. - AcceptToS bool - // CA is the CA to use - CA string - // ChallengeProvider is a go-acme/lego challenge provider. Set this if you - // want to use DNS Challenges. Otherwise, tls-alpn-01 will be used - ChallengeProvider challenge.Provider - // Issue certificates for domains on demand. Otherwise, certs will be - // retrieved / issued on start-up. - OnDemand bool - // TODO - Cache interface{} -} - -// AcceptToS indicates whether you accept your CA's terms of service -func AcceptToS(b bool) Option { - return func(o *Options) { - o.AcceptToS = b - } -} - -// CA sets the CA of an acme.Options -func CA(CA string) Option { - return func(o *Options) { - o.CA = CA - } -} - -// ChallengeProvider sets the Challenge provider of an acme.Options -// if set, it enables the DNS challenge, otherwise tls-alpn-01 will be used. -func ChallengeProvider(p challenge.Provider) Option { - return func(o *Options) { - o.ChallengeProvider = p - } -} - -// OnDemand enables on-demand certificate issuance. Not recommended for use -// with the DNS challenge, as the first connection may be very slow. -func OnDemand(b bool) Option { - return func(o *Options) { - o.OnDemand = b - } -} - -// Default uses the Let's Encrypt Production CA, with DNS Challenge disabled. -func Default() []Option { - return []Option{ - AcceptToS(true), - CA(LetsEncryptProductionCA), - OnDemand(true), - } -} diff --git a/api/server/acme/certmagic/certmagic.go b/api/server/acme/certmagic/certmagic.go index 68e36f64..6d12e9d6 100644 --- a/api/server/acme/certmagic/certmagic.go +++ b/api/server/acme/certmagic/certmagic.go @@ -2,6 +2,7 @@ package certmagic import ( + "log" "net" "github.com/mholt/certmagic" @@ -36,6 +37,12 @@ func New(options ...acme.Option) acme.Provider { op(o) } } + if o.Cache != nil { + if _, ok := o.Cache.(certmagic.Storage); !ok { + log.Fatal("ACME: cache provided doesn't implement certmagic's Storage interface") + } + } + return &certmagicProvider{ opts: o, } diff --git a/api/server/acme/certmagic/certmagic_test.go b/api/server/acme/certmagic/certmagic_test.go index dc727101..52ede19c 100644 --- a/api/server/acme/certmagic/certmagic_test.go +++ b/api/server/acme/certmagic/certmagic_test.go @@ -1,19 +1,24 @@ package certmagic import ( + "os" "testing" "github.com/go-acme/lego/v3/providers/dns/cloudflare" - // "github.com/micro/go-micro/api/server/acme" + "github.com/mholt/certmagic" + "github.com/micro/go-micro/api/server/acme" + "github.com/micro/go-micro/sync/lock/memory" ) func TestCertMagic(t *testing.T) { - // TODO: Travis doesn't let us bind :443 - // l, err := New().NewListener() - // if err != nil { - // t.Error(err.Error()) - // } - // l.Close() + if len(os.Getenv("IN_TRAVIS_CI")) != 0 { + t.Skip("Travis doesn't let us bind :443") + } + l, err := New().NewListener() + if err != nil { + t.Error(err.Error()) + } + l.Close() c := cloudflare.NewDefaultConfig() c.AuthEmail = "" @@ -21,19 +26,30 @@ func TestCertMagic(t *testing.T) { c.AuthToken = "test" c.ZoneToken = "test" - _, err := cloudflare.NewDNSProviderConfig(c) + p, err := cloudflare.NewDNSProviderConfig(c) if err != nil { t.Error(err.Error()) } - // TODO: Travis doesn't let us bind :443 - // l, err = New(acme.AcceptTLS(true), - // acme.CA(acme.LetsEncryptStagingCA), - // acme.ChallengeProvider(p), - // ).NewListener() + l, err = New(acme.AcceptToS(true), + acme.CA(acme.LetsEncryptStagingCA), + acme.ChallengeProvider(p), + ).NewListener() - // if err != nil { - // t.Error(err.Error()) - // } - // l.Close() + if err != nil { + t.Error(err.Error()) + } + l.Close() +} + +func TestStorageImplementation(t *testing.T) { + var s certmagic.Storage + s = &storage{ + lock: memory.NewLock(), + } + if err := s.Lock("test"); err != nil { + t.Error(err) + } + s.Unlock("test") + New(acme.Cache(s)) } diff --git a/api/server/acme/certmagic/storage.go b/api/server/acme/certmagic/storage.go new file mode 100644 index 00000000..b1bb92b4 --- /dev/null +++ b/api/server/acme/certmagic/storage.go @@ -0,0 +1,134 @@ +package certmagic + +import ( + "bytes" + "encoding/gob" + "fmt" + "path" + "strings" + "time" + + "github.com/mholt/certmagic" + "github.com/micro/go-micro/store" + "github.com/micro/go-micro/sync/lock" +) + +// file represents a "file" that will be stored in store.Store - the contents and last modified time +type file struct { + // last modified time + lastModified time.Time + // contents + contents []byte +} + +// storage is an implementation of certmagic.Storage using micro's sync.Map and store.Store interfaces. +// As certmagic storage expects a filesystem (with stat() abilities) we have to implement +// the bare minimum of metadata. +type storage struct { + lock lock.Lock + store store.Store +} + +func (s *storage) Lock(key string) error { + return s.lock.Acquire(key, lock.TTL(10*time.Minute)) +} + +func (s *storage) Unlock(key string) error { + return s.lock.Release(key) +} + +func (s *storage) Store(key string, value []byte) error { + f := file{ + lastModified: time.Now(), + contents: value, + } + buf := &bytes.Buffer{} + e := gob.NewEncoder(buf) + if err := e.Encode(f); err != nil { + return err + } + r := &store.Record{ + Key: key, + Value: buf.Bytes(), + } + return s.store.Write(r) +} + +func (s *storage) Load(key string) ([]byte, error) { + records, err := s.store.Read(key) + if err != nil { + return nil, err + } + if len(records) != 1 { + return nil, fmt.Errorf("ACME Storage: multiple records matched key %s", key) + } + b := bytes.NewBuffer(records[0].Value) + d := gob.NewDecoder(b) + var f file + err = d.Decode(&f) + if err != nil { + return nil, err + } + return f.contents, nil +} + +func (s *storage) Delete(key string) error { + return s.store.Delete(key) +} + +func (s *storage) Exists(key string) bool { + _, err := s.store.Read() + if err != nil { + return false + } + return true +} + +func (s *storage) List(prefix string, recursive bool) ([]string, error) { + records, err := s.store.Sync() + if err != nil { + return nil, err + } + var results []string + for _, r := range records { + if strings.HasPrefix(r.Key, prefix) { + results = append(results, r.Key) + } + } + if recursive { + return results, nil + } + keysMap := make(map[string]bool) + for _, key := range results { + dir := strings.Split(strings.TrimPrefix(key, prefix+"/"), "/") + keysMap[dir[0]] = true + } + results = make([]string, 0) + for k := range keysMap { + results = append(results, path.Join(prefix, k)) + } + return results, nil +} + +func (s *storage) Stat(key string) (certmagic.KeyInfo, error) { + records, err := s.store.Read(key) + if err != nil { + return certmagic.KeyInfo{}, err + } + if len(records) != 1 { + return certmagic.KeyInfo{}, fmt.Errorf("ACME Storage: multiple records matched key %s", key) + } + b := bytes.NewBuffer(records[0].Value) + d := gob.NewDecoder(b) + var f file + err = d.Decode(&f) + if err != nil { + return certmagic.KeyInfo{}, err + } + return certmagic.KeyInfo{ + Key: key, + Modified: f.lastModified, + Size: int64(len(f.contents)), + IsTerminal: false, + }, nil +} diff --git a/api/server/acme/options.go b/api/server/acme/options.go new file mode 100644 index 00000000..cd7f55fd --- /dev/null +++ b/api/server/acme/options.go @@ -0,0 +1,73 @@ +package acme + +import "github.com/go-acme/lego/v3/challenge" + +// Option (or Options) are passed to New() to configure providers +type Option func(o *Options) + +// Options represents various options you can present to ACME providers +type Options struct { + // AcceptTLS must be set to true to indicate that you have read your + // provider's terms of service. + AcceptToS bool + // CA is the CA to use + CA string + // ChallengeProvider is a go-acme/lego challenge provider. Set this if you + // want to use DNS Challenges. Otherwise, tls-alpn-01 will be used + ChallengeProvider challenge.Provider + // Issue certificates for domains on demand. Otherwise, certs will be + // retrieved / issued on start-up. + OnDemand bool + // Cache is a storage interface. Most ACME libraries have an cache, but + // there's no defined interface, so if you consume this option + // sanity check it before using. + Cache interface{} +} + +// AcceptToS indicates whether you accept your CA's terms of service +func AcceptToS(b bool) Option { + return func(o *Options) { + o.AcceptToS = b + } +} + +// CA sets the CA of an acme.Options +func CA(CA string) Option { + return func(o *Options) { + o.CA = CA + } +} + +// ChallengeProvider sets the Challenge provider of an acme.Options +// if set, it enables the DNS challenge, otherwise tls-alpn-01 will be used. +func ChallengeProvider(p challenge.Provider) Option { + return func(o *Options) { + o.ChallengeProvider = p + } +} + +// OnDemand enables on-demand certificate issuance. Not recommended for use +// with the DNS challenge, as the first connection may be very slow. +func OnDemand(b bool) Option { + return func(o *Options) { + o.OnDemand = b + } +} + +// Cache provides a cache / storage interface to the underlying ACME library +// as there is no standard, this needs to be validated by the underlying +// implentation. +func Cache(c interface{}) Option { + return func(o *Options) { + o.Cache = c + } +} + +// Default uses the Let's Encrypt Production CA, with DNS Challenge disabled. +func Default() []Option { + return []Option{ + AcceptToS(true), + CA(LetsEncryptProductionCA), + OnDemand(true), + } +}