diff --git a/.travis.yml b/.travis.yml index f1af03bd..e37f0796 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,10 @@ go: - 1.6.3 - 1.7 - tip +services: + - memcached +env: + - MEMCACHED_HOSTS=localhost:11211 install: - go get -t ./... script: diff --git a/cli.go b/cli.go index 4a1d1211..64221fdf 100644 --- a/cli.go +++ b/cli.go @@ -138,6 +138,10 @@ func main() { Name: "webroot", Usage: "Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge", }, + cli.StringSliceFlag{ + Name: "memcached-host", + Usage: "Set the memcached host(s) to use for HTTP based challenges. Challenges will be written to all specified hosts.", + }, cli.StringFlag{ Name: "http", Usage: "Set the port and interface to use for HTTP based challenges to listen on. Supported: interface:port or :port", diff --git a/cli_handlers.go b/cli_handlers.go index 2e06b85f..6647c2ac 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -31,6 +31,7 @@ import ( "github.com/xenolf/lego/providers/dns/rfc2136" "github.com/xenolf/lego/providers/dns/route53" "github.com/xenolf/lego/providers/dns/vultr" + "github.com/xenolf/lego/providers/http/memcached" "github.com/xenolf/lego/providers/http/webroot" ) @@ -101,6 +102,18 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { // infer that the user also wants to exclude all other challenges client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSSNI01}) } + if c.GlobalIsSet("memcached-host") { + provider, err := memcached.NewMemcachedProvider(c.GlobalStringSlice("memcached-host")) + if err != nil { + logger().Fatal(err) + } + + client.SetChallengeProvider(acme.HTTP01, provider) + + // --memcached-host=foo:11211 indicates that the user specifically want to do a HTTP challenge + // infer that the user also wants to exclude all other challenges + client.ExcludeChallenges([]acme.Challenge{acme.DNS01, acme.TLSSNI01}) + } if c.GlobalIsSet("http") { if strings.Index(c.GlobalString("http"), ":") == -1 { logger().Fatalf("The --http switch only accepts interface:port or :port for its argument.") diff --git a/providers/http/memcached/README.md b/providers/http/memcached/README.md new file mode 100644 index 00000000..f14d216d --- /dev/null +++ b/providers/http/memcached/README.md @@ -0,0 +1,15 @@ +# Memcached http provider + +Publishes challenges into memcached where they can be retrieved by nginx. Allows +specifying multiple memcached servers and the responses will be published to all +of them, making it easier to verify when your domain is hosted on a cluster of +servers. + +Example nginx config: + +``` + location /.well-known/acme-challenge/ { + set $memcached_key "$uri"; + memcached_pass 127.0.0.1:11211; + } +``` diff --git a/providers/http/memcached/memcached.go b/providers/http/memcached/memcached.go new file mode 100644 index 00000000..9c5f6c0b --- /dev/null +++ b/providers/http/memcached/memcached.go @@ -0,0 +1,59 @@ +// Package webroot implements a HTTP provider for solving the HTTP-01 challenge using web server's root path. +package memcached + +import ( + "fmt" + "path" + + "github.com/rainycape/memcache" + "github.com/xenolf/lego/acme" +) + +// HTTPProvider implements ChallengeProvider for `http-01` challenge +type MemcachedProvider struct { + hosts []string +} + +// NewHTTPProvider returns a HTTPProvider instance with a configured webroot path +func NewMemcachedProvider(hosts []string) (*MemcachedProvider, error) { + if len(hosts) == 0 { + return nil, fmt.Errorf("No memcached hosts provided") + } + + c := &MemcachedProvider{ + hosts: hosts, + } + + return c, nil +} + +// Present makes the token available at `HTTP01ChallengePath(token)` by creating a file in the given webroot path +func (w *MemcachedProvider) Present(domain, token, keyAuth string) error { + var errs []error + + challengePath := path.Join("/", acme.HTTP01ChallengePath(token)) + for _, host := range w.hosts { + mc, err := memcache.New(host) + if err != nil { + errs = append(errs, err) + continue + } + mc.Add(&memcache.Item{ + Key: challengePath, + Value: []byte(keyAuth), + Expiration: 60, + }) + } + + if len(errs) == len(w.hosts) { + return fmt.Errorf("Unable to store key in any of the memcache hosts -> %v", errs) + } + + return nil +} + +// CleanUp removes the file created for the challenge +func (w *MemcachedProvider) CleanUp(domain, token, keyAuth string) error { + // Memcached will clean up itself, that's what expiration is for. + return nil +} diff --git a/providers/http/memcached/memcached_test.go b/providers/http/memcached/memcached_test.go new file mode 100644 index 00000000..287a3330 --- /dev/null +++ b/providers/http/memcached/memcached_test.go @@ -0,0 +1,111 @@ +package memcached + +import ( + "os" + "path" + "strings" + "testing" + + "github.com/rainycape/memcache" + "github.com/stretchr/testify/assert" + "github.com/xenolf/lego/acme" +) + +var ( + memcachedHosts []string +) + +const ( + domain = "lego.test" + token = "foo" + keyAuth = "bar" +) + +func init() { + memcachedHostsStr := os.Getenv("MEMCACHED_HOSTS") + if len(memcachedHostsStr) > 0 { + memcachedHosts = strings.Split(memcachedHostsStr, ",") + } +} + +func TestNewMemcachedProviderEmpty(t *testing.T) { + emptyHosts := make([]string, 0) + _, err := NewMemcachedProvider(emptyHosts) + assert.EqualError(t, err, "No memcached hosts provided") +} + +func TestNewMemcachedProviderValid(t *testing.T) { + if len(memcachedHosts) == 0 { + t.Skip("Skipping memcached tests") + } + _, err := NewMemcachedProvider(memcachedHosts) + assert.NoError(t, err) +} + +func TestMemcachedPresentSingleHost(t *testing.T) { + if len(memcachedHosts) == 0 { + t.Skip("Skipping memcached tests") + } + p, err := NewMemcachedProvider(memcachedHosts[0:1]) + assert.NoError(t, err) + + challengePath := path.Join("/", acme.HTTP01ChallengePath(token)) + + err = p.Present(domain, token, keyAuth) + assert.NoError(t, err) + mc, err := memcache.New(memcachedHosts[0]) + assert.NoError(t, err) + i, err := mc.Get(challengePath) + assert.NoError(t, err) + assert.Equal(t, i.Value, []byte(keyAuth)) +} + +func TestMemcachedPresentMultiHost(t *testing.T) { + if len(memcachedHosts) <= 1 { + t.Skip("Skipping memcached multi-host tests") + } + p, err := NewMemcachedProvider(memcachedHosts) + assert.NoError(t, err) + + challengePath := path.Join("/", acme.HTTP01ChallengePath(token)) + + err = p.Present(domain, token, keyAuth) + assert.NoError(t, err) + for _, host := range memcachedHosts { + mc, err := memcache.New(host) + assert.NoError(t, err) + i, err := mc.Get(challengePath) + assert.NoError(t, err) + assert.Equal(t, i.Value, []byte(keyAuth)) + } +} + +func TestMemcachedPresentPartialFailureMultiHost(t *testing.T) { + if len(memcachedHosts) == 0 { + t.Skip("Skipping memcached tests") + } + hosts := append(memcachedHosts, "5.5.5.5:11211") + p, err := NewMemcachedProvider(hosts) + assert.NoError(t, err) + + challengePath := path.Join("/", acme.HTTP01ChallengePath(token)) + + err = p.Present(domain, token, keyAuth) + assert.NoError(t, err) + for _, host := range memcachedHosts { + mc, err := memcache.New(host) + assert.NoError(t, err) + i, err := mc.Get(challengePath) + assert.NoError(t, err) + assert.Equal(t, i.Value, []byte(keyAuth)) + } +} + +func TestMemcachedCleanup(t *testing.T) { + if len(memcachedHosts) == 0 { + t.Skip("Skipping memcached tests") + } + p, err := NewMemcachedProvider(memcachedHosts) + assert.NoError(t, err) + assert.NoError(t, p.CleanUp(domain, token, keyAuth)) +}