Add DNS provider: Lightsail (#460)

* add lightsail dns provider

* fix lint errors

* update exoscale.go

* add the docs for lightsail provider
This commit is contained in:
Derek Chen 2018-02-18 10:27:58 -05:00 committed by Matt Holt
parent 4e330710a7
commit bacb545c7a
6 changed files with 294 additions and 1 deletions

1
cli.go
View file

@ -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")

View file

@ -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":

View file

@ -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
}

View file

@ -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
}

View file

@ -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")
}

View file

@ -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
}