diff --git a/cli.go b/cli.go index 5f0dc57d..2f4b8407 100644 --- a/cli.go +++ b/cli.go @@ -213,6 +213,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tgandiv5:\tGANDIV5_API_KEY") fmt.Fprintln(w, "\tgcloud:\tGCE_PROJECT, GCE_SERVICE_ACCOUNT_FILE") fmt.Fprintln(w, "\tlinode:\tLINODE_API_KEY") + fmt.Fprintln(w, "\tlightsail:\tAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, DNS_ZONE") fmt.Fprintln(w, "\tmanual:\tnone") fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_API_USER, NAMECHEAP_API_KEY") fmt.Fprintln(w, "\trackspace:\tRACKSPACE_USER, RACKSPACE_API_KEY") diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 06235309..ada957cb 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -17,8 +17,9 @@ import ( "github.com/xenolf/lego/providers/dns/exoscale" "github.com/xenolf/lego/providers/dns/gandi" "github.com/xenolf/lego/providers/dns/gandiv5" - "github.com/xenolf/lego/providers/dns/googlecloud" "github.com/xenolf/lego/providers/dns/godaddy" + "github.com/xenolf/lego/providers/dns/googlecloud" + "github.com/xenolf/lego/providers/dns/lightsail" "github.com/xenolf/lego/providers/dns/linode" "github.com/xenolf/lego/providers/dns/namecheap" "github.com/xenolf/lego/providers/dns/ns1" @@ -63,6 +64,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error) provider, err = googlecloud.NewDNSProvider() case "godaddy": provider, err = godaddy.NewDNSProvider() + case "lightsail": + provider, err = lightsail.NewDNSProvider() case "linode": provider, err = linode.NewDNSProvider() case "manual": diff --git a/providers/dns/lightsail/lightsail.go b/providers/dns/lightsail/lightsail.go new file mode 100644 index 00000000..a4d2efaf --- /dev/null +++ b/providers/dns/lightsail/lightsail.go @@ -0,0 +1,107 @@ +// Package lightsail implements a DNS provider for solving the DNS-01 challenge +// using AWS Lightsail DNS. +package lightsail + +import ( + "math/rand" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/client" + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/lightsail" + "github.com/xenolf/lego/acme" +) + +const ( + maxRetries = 5 +) + +// DNSProvider implements the acme.ChallengeProvider interface +type DNSProvider struct { + client *lightsail.Lightsail +} + +// customRetryer implements the client.Retryer interface by composing the +// DefaultRetryer. It controls the logic for retrying recoverable request +// errors (e.g. when rate limits are exceeded). +type customRetryer struct { + client.DefaultRetryer +} + +// RetryRules overwrites the DefaultRetryer's method. +// It uses a basic exponential backoff algorithm that returns an initial +// delay of ~400ms with an upper limit of ~30 seconds which should prevent +// causing a high number of consecutive throttling errors. +// For reference: Route 53 enforces an account-wide(!) 5req/s query limit. +func (d customRetryer) RetryRules(r *request.Request) time.Duration { + retryCount := r.RetryCount + if retryCount > 7 { + retryCount = 7 + } + + delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200) + return time.Duration(delay) * time.Millisecond +} + +// NewDNSProvider returns a DNSProvider instance configured for the AWS +// Lightsail service. +// +// AWS Credentials are automatically detected in the following locations +// and prioritized in the following order: +// 1. Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, +// [AWS_SESSION_TOKEN], [DNS_ZONE] +// 2. Shared credentials file (defaults to ~/.aws/credentials) +// 3. Amazon EC2 IAM role +// +// public hosted zone via the FQDN. +// +// See also: https://github.com/aws/aws-sdk-go/wiki/configuring-sdk +func NewDNSProvider() (*DNSProvider, error) { + r := customRetryer{} + r.NumMaxRetries = maxRetries + config := request.WithRetryer(aws.NewConfig(), r) + client := lightsail.New(session.New(config)) + + return &DNSProvider{ + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters +func (r *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + value = `"` + value + `"` + err := r.newTxtRecord(domain, fqdn, value) + return err +} + +// CleanUp removes the TXT record matching the specified parameters +func (r *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + value = `"` + value + `"` + params := &lightsail.DeleteDomainEntryInput{ + DomainName: aws.String(domain), + DomainEntry: &lightsail.DomainEntry{ + Name: aws.String(fqdn), + Type: aws.String("TXT"), + Target: aws.String(value), + }, + } + _, err := r.client.DeleteDomainEntry(params) + return err +} + +func (r *DNSProvider) newTxtRecord(domain string, fqdn string, value string) error { + params := &lightsail.CreateDomainEntryInput{ + DomainName: aws.String(domain), + DomainEntry: &lightsail.DomainEntry{ + Name: aws.String(fqdn), + Target: aws.String(value), + Type: aws.String("TXT"), + }, + } + _, err := r.client.CreateDomainEntry(params) + return err +} diff --git a/providers/dns/lightsail/lightsail_integration_test.go b/providers/dns/lightsail/lightsail_integration_test.go new file mode 100644 index 00000000..ee6216ea --- /dev/null +++ b/providers/dns/lightsail/lightsail_integration_test.go @@ -0,0 +1,68 @@ +package lightsail + +import ( + "fmt" + "os" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/lightsail" +) + +func TestLightsailTTL(t *testing.T) { + + m, err := testGetAndPreCheck() + if err != nil { + t.Skip(err.Error()) + } + + provider, err := NewDNSProvider() + if err != nil { + t.Fatalf("Fatal: %s", err.Error()) + } + + err = provider.Present(m["lightsailDomain"], "foo", "bar") + if err != nil { + t.Fatalf("Fatal: %s", err.Error()) + } + // we need a separate Lightshail client here as the one in the DNS provider is + // unexported. + fqdn := "_acme-challenge." + m["lightsailDomain"] + svc := lightsail.New(session.New()) + if err != nil { + provider.CleanUp(m["lightsailDomain"], "foo", "bar") + t.Fatalf("Fatal: %s", err.Error()) + } + params := &lightsail.GetDomainInput{ + DomainName: aws.String(m["lightsailDomain"]), + } + resp, err := svc.GetDomain(params) + if err != nil { + provider.CleanUp(m["lightsailDomain"], "foo", "bar") + t.Fatalf("Fatal: %s", err.Error()) + } + entries := resp.Domain.DomainEntries + for _, entry := range entries { + if *entry.Type == "TXT" && *entry.Name == fqdn { + provider.CleanUp(m["lightsailDomain"], "foo", "bar") + return + } + } + provider.CleanUp(m["lightsailDomain"], "foo", "bar") + t.Fatalf("Could not find a TXT record for _acme-challenge.%s", m["lightsailDomain"]) +} + +func testGetAndPreCheck() (map[string]string, error) { + m := map[string]string{ + "lightsailKey": os.Getenv("AWS_ACCESS_KEY_ID"), + "lightsailSecret": os.Getenv("AWS_SECRET_ACCESS_KEY"), + "lightsailDomain": os.Getenv("DNS_ZONE"), + } + for _, v := range m { + if v == "" { + return nil, fmt.Errorf("AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and R53_DOMAIN are needed to run this test") + } + } + return m, nil +} diff --git a/providers/dns/lightsail/lightsail_test.go b/providers/dns/lightsail/lightsail_test.go new file mode 100644 index 00000000..d443da54 --- /dev/null +++ b/providers/dns/lightsail/lightsail_test.go @@ -0,0 +1,76 @@ +package lightsail + +import ( + "net/http/httptest" + "os" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/lightsail" + "github.com/stretchr/testify/assert" +) + +var ( + lightsailSecret string + lightsailKey string + lightsailZone string +) + +func init() { + lightsailKey = os.Getenv("AWS_ACCESS_KEY_ID") + lightsailSecret = os.Getenv("AWS_SECRET_ACCESS_KEY") +} + +func restoreLightsailEnv() { + os.Setenv("AWS_ACCESS_KEY_ID", lightsailKey) + os.Setenv("AWS_SECRET_ACCESS_KEY", lightsailSecret) + os.Setenv("AWS_REGION", "us-east-1") + os.Setenv("AWS_HOSTED_ZONE_ID", lightsailZone) +} + +func makeLightsailProvider(ts *httptest.Server) *DNSProvider { + config := &aws.Config{ + Credentials: credentials.NewStaticCredentials("abc", "123", " "), + Endpoint: aws.String(ts.URL), + Region: aws.String("mock-region"), + MaxRetries: aws.Int(1), + } + + client := lightsail.New(session.New(config)) + return &DNSProvider{client: client} +} + +func TestCredentialsFromEnv(t *testing.T) { + os.Setenv("AWS_ACCESS_KEY_ID", "123") + os.Setenv("AWS_SECRET_ACCESS_KEY", "123") + os.Setenv("AWS_REGION", "us-east-1") + + config := &aws.Config{ + CredentialsChainVerboseErrors: aws.Bool(true), + } + + sess := session.New(config) + _, err := sess.Config.Credentials.Get() + assert.NoError(t, err, "Expected credentials to be set from environment") + + restoreLightsailEnv() +} + +func TestLightsailPresent(t *testing.T) { + mockResponses := MockResponseMap{ + "/": MockResponse{StatusCode: 200, Body: ""}, + } + + ts := newMockServer(t, mockResponses) + defer ts.Close() + + provider := makeLightsailProvider(ts) + + domain := "example.com" + keyAuth := "123456d==" + + err := provider.Present(domain, "", keyAuth) + assert.NoError(t, err, "Expected Present to return no error") +} diff --git a/providers/dns/lightsail/testutil_test.go b/providers/dns/lightsail/testutil_test.go new file mode 100644 index 00000000..11141216 --- /dev/null +++ b/providers/dns/lightsail/testutil_test.go @@ -0,0 +1,38 @@ +package lightsail + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// MockResponse represents a predefined response used by a mock server +type MockResponse struct { + StatusCode int + Body string +} + +// MockResponseMap maps request paths to responses +type MockResponseMap map[string]MockResponse + +func newMockServer(t *testing.T, responses MockResponseMap) *httptest.Server { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + resp, ok := responses[path] + if !ok { + msg := fmt.Sprintf("Requested path not found in response map: %s", path) + require.FailNow(t, msg) + } + + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(resp.StatusCode) + w.Write([]byte(resp.Body)) + })) + + time.Sleep(100 * time.Millisecond) + return ts +}