Merge branch 'add-dns-challenge'

This commit is contained in:
xenolf 2016-01-22 01:51:10 +01:00
commit 602aeba6c1
9 changed files with 987 additions and 0 deletions

View file

@ -1 +1,140 @@
package acme package acme
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/miekg/dns"
)
type preCheckDNSFunc func() bool
var preCheckDNS = func() bool {
return true
}
var preCheckDNSFallbackCount = 5
// DNSProvider represents a service for creating dns records.
type DNSProvider interface {
// CreateTXT creates a TXT record
CreateTXTRecord(fqdn, value string, ttl int) error
RemoveTXTRecord(fqdn, value string, ttl int) error
}
// dnsChallenge implements the dns-01 challenge according to ACME 7.5
type dnsChallenge struct {
jws *jws
provider DNSProvider
}
func (s *dnsChallenge) Solve(chlng challenge, domain string) error {
logf("[INFO] acme: Trying to solve DNS-01")
// Generate the Key Authorization for the challenge
keyAuth, err := getKeyAuthorization(chlng.Token, &s.jws.privKey.PublicKey)
if err != nil {
return err
}
keyAuthShaBytes := sha256.Sum256([]byte(keyAuth))
// base64URL encoding without padding
keyAuthSha := base64.URLEncoding.EncodeToString(keyAuthShaBytes[:sha256.Size])
keyAuthSha = strings.TrimRight(keyAuthSha, "=")
fqdn := fmt.Sprintf("_acme-challenge.%s.", domain)
if err = s.provider.CreateTXTRecord(fqdn, keyAuthSha, 120); err != nil {
return err
}
if preCheckDNS() {
// check if the expected DNS entry was created. If not wait for some time and try again.
m := new(dns.Msg)
m.SetQuestion(domain+".", dns.TypeSOA)
c := new(dns.Client)
in, _, err := c.Exchange(m, "8.8.8.8:53")
if err != nil {
return err
}
var authorativeNS string
for _, answ := range in.Answer {
soa := answ.(*dns.SOA)
authorativeNS = soa.Ns
}
fallbackCnt := 0
for fallbackCnt < preCheckDNSFallbackCount {
m.SetQuestion(fqdn, dns.TypeTXT)
in, _, err = c.Exchange(m, authorativeNS+":53")
if err != nil {
return err
}
if len(in.Answer) > 0 {
break
}
fallbackCnt++
if fallbackCnt >= preCheckDNSFallbackCount {
return errors.New("Could not retrieve the value from DNS in a timely manner. Aborting.")
}
time.Sleep(time.Second * time.Duration(fallbackCnt))
}
}
jsonBytes, err := json.Marshal(challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
if err != nil {
return errors.New("Failed to marshal network message...")
}
// Tell the server we handle DNS-01
resp, err := s.jws.post(chlng.URI, jsonBytes)
if err != nil {
return fmt.Errorf("Failed to post JWS message. -> %v", err)
}
// Repeatedly check the server for an updated status on our request.
var challengeResponse challenge
Loop:
for {
if resp.StatusCode >= http.StatusBadRequest {
return handleHTTPError(resp)
}
err = json.NewDecoder(resp.Body).Decode(&challengeResponse)
resp.Body.Close()
if err != nil {
return err
}
switch challengeResponse.Status {
case "valid":
logf("The server validated our request")
break Loop
case "pending":
break
case "invalid":
return errors.New("The server could not validate our request.")
default:
return errors.New("The server returned an unexpected state.")
}
time.Sleep(1 * time.Second)
resp, err = http.Get(chlng.URI)
}
if err = s.provider.RemoveTXTRecord(fqdn, keyAuthSha, 120); err != nil {
logf("[WARN] acme: Failed to cleanup DNS record. -> %v ", err)
}
return nil
}

View file

@ -0,0 +1,158 @@
package acme
import (
"fmt"
"os"
"strings"
"github.com/crackcomm/cloudflare"
"golang.org/x/net/context"
)
// DNSProviderCloudFlare is an implementation of the DNSProvider interface
type DNSProviderCloudFlare struct {
client *cloudflare.Client
ctx context.Context
}
// NewDNSProviderCloudFlare returns a DNSProviderCloudFlare instance with a configured cloudflare client.
// Authentication is either done using the passed credentials or - when empty - using the environment
// variables CLOUDFLARE_EMAIL and CLOUDFLARE_API_KEY.
func NewDNSProviderCloudFlare(cloudflareEmail, cloudflareKey string) (*DNSProviderCloudFlare, error) {
if cloudflareEmail == "" || cloudflareKey == "" {
cloudflareEmail, cloudflareKey = envAuth()
if cloudflareEmail == "" || cloudflareKey == "" {
return nil, fmt.Errorf("CloudFlare credentials missing")
}
}
c := &DNSProviderCloudFlare{
client: cloudflare.New(&cloudflare.Options{cloudflareEmail, cloudflareKey}),
ctx: context.Background(),
}
return c, nil
}
// CreateTXTRecord creates a TXT record using the specified parameters
func (c *DNSProviderCloudFlare) CreateTXTRecord(fqdn, value string, ttl int) error {
zoneID, err := c.getHostedZoneID(fqdn)
if err != nil {
return err
}
record := newTxtRecord(zoneID, fqdn, value, ttl)
err = c.client.Records.Create(c.ctx, record)
if err != nil {
return fmt.Errorf("CloudFlare API call failed: %v", err)
}
return nil
}
// RemoveTXTRecord removes the TXT record matching the specified parameters
func (c *DNSProviderCloudFlare) RemoveTXTRecord(fqdn, value string, ttl int) error {
records, err := c.findTxtRecords(fqdn)
if err != nil {
return err
}
for _, rec := range records {
err := c.client.Records.Delete(c.ctx, rec.ZoneID, rec.ID)
if err != nil {
return fmt.Errorf("CloudFlare API call has failed: %v", err)
}
}
return nil
}
func (c *DNSProviderCloudFlare) findTxtRecords(fqdn string) ([]*cloudflare.Record, error) {
zoneID, err := c.getHostedZoneID(fqdn)
if err != nil {
return nil, err
}
var records []*cloudflare.Record
result, err := c.client.Records.List(c.ctx, zoneID)
if err != nil {
return records, fmt.Errorf("CloudFlare API call has failed: %v", err)
}
name := unFqdn(fqdn)
for _, rec := range result {
if rec.Name == name && rec.Type == "TXT" {
records = append(records, rec)
}
}
return records, nil
}
func (c *DNSProviderCloudFlare) getHostedZoneID(fqdn string) (string, error) {
zones, err := c.client.Zones.List(c.ctx)
if err != nil {
return "", fmt.Errorf("CloudFlare API call failed: %v", err)
}
var hostedZone cloudflare.Zone
for _, zone := range zones {
name := toFqdn(zone.Name)
if strings.HasSuffix(fqdn, name) {
if len(zone.Name) > len(hostedZone.Name) {
hostedZone = *zone
}
}
}
if hostedZone.ID == "" {
return "", fmt.Errorf("No matching CloudFlare zone found for domain %s", fqdn)
}
return hostedZone.ID, nil
}
func newTxtRecord(zoneID, fqdn, value string, ttl int) *cloudflare.Record {
name := unFqdn(fqdn)
return &cloudflare.Record{
Type: "TXT",
Name: name,
Content: value,
TTL: sanitizeTTL(ttl),
ZoneID: zoneID,
}
}
func toFqdn(name string) string {
n := len(name)
if n == 0 || name[n-1] == '.' {
return name
}
return name + "."
}
func unFqdn(name string) string {
n := len(name)
if n != 0 && name[n-1] == '.' {
return name[:n-1]
}
return name
}
// TTL must be between 120 and 86400 seconds
func sanitizeTTL(ttl int) int {
if ttl < 120 {
ttl = 120
} else if ttl > 86400 {
ttl = 86400
}
return ttl
}
func envAuth() (email, apiKey string) {
email = os.Getenv("CLOUDFLARE_EMAIL")
apiKey = os.Getenv("CLOUDFLARE_API_KEY")
if len(email) == 0 || len(apiKey) == 0 {
return "", ""
}
return
}

View file

@ -0,0 +1,83 @@
package acme
import (
"fmt"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
var (
cflareLiveTest bool
cflareEmail string
cflareAPIKey string
cflareDomain string
)
func init() {
cflareEmail = os.Getenv("CLOUDFLARE_EMAIL")
cflareAPIKey = os.Getenv("CLOUDFLARE_API_KEY")
cflareDomain = os.Getenv("CLOUDFLARE_DOMAIN")
if len(cflareEmail) > 0 && len(cflareAPIKey) > 0 && len(cflareDomain) > 0 {
cflareLiveTest = true
}
}
func restoreCloudFlareEnv() {
os.Setenv("CLOUDFLARE_EMAIL", cflareEmail)
os.Setenv("CLOUDFLARE_API_KEY", cflareAPIKey)
}
func TestNewDNSProviderCloudFlareValid(t *testing.T) {
os.Setenv("CLOUDFLARE_EMAIL", "")
os.Setenv("CLOUDFLARE_API_KEY", "")
_, err := NewDNSProviderCloudFlare("123", "123")
assert.NoError(t, err)
restoreCloudFlareEnv()
}
func TestNewDNSProviderCloudFlareValidEnv(t *testing.T) {
os.Setenv("CLOUDFLARE_EMAIL", "test@example.com")
os.Setenv("CLOUDFLARE_API_KEY", "123")
_, err := NewDNSProviderCloudFlare("", "")
assert.NoError(t, err)
restoreCloudFlareEnv()
}
func TestNewDNSProviderCloudFlareMissingCredErr(t *testing.T) {
os.Setenv("CLOUDFLARE_EMAIL", "")
os.Setenv("CLOUDFLARE_API_KEY", "")
_, err := NewDNSProviderCloudFlare("", "")
assert.EqualError(t, err, "CloudFlare credentials missing")
restoreCloudFlareEnv()
}
func TestCloudFlareCreateTXTRecord(t *testing.T) {
if !cflareLiveTest {
t.Skip("skipping live test")
}
provider, err := NewDNSProviderCloudFlare(cflareEmail, cflareAPIKey)
assert.NoError(t, err)
fqdn := fmt.Sprintf("_acme-challenge.123.%s.", cflareDomain)
err = provider.CreateTXTRecord(fqdn, "123d==", 120)
assert.NoError(t, err)
}
func TestCloudFlareRemoveTXTRecord(t *testing.T) {
if !cflareLiveTest {
t.Skip("skipping live test")
}
time.Sleep(time.Second * 1)
provider, err := NewDNSProviderCloudFlare(cflareEmail, cflareAPIKey)
assert.NoError(t, err)
fqdn := fmt.Sprintf("_acme-challenge.123.%s.", cflareDomain)
err = provider.RemoveTXTRecord(fqdn, "123d==", 120)
assert.NoError(t, err)
}

View file

@ -0,0 +1,38 @@
package acme
import (
"bufio"
"fmt"
"os"
)
const (
dnsTemplate = "%s %d IN TXT \"%s\""
)
// DNSProviderManual is an implementation of the DNSProvider interface
type DNSProviderManual struct{}
// NewDNSProviderManual returns a DNSProviderManual instance.
func NewDNSProviderManual() (*DNSProviderManual, error) {
return &DNSProviderManual{}, nil
}
// CreateTXTRecord prints instructions for manually creating the TXT record
func (*DNSProviderManual) CreateTXTRecord(fqdn, value string, ttl int) error {
dnsRecord := fmt.Sprintf(dnsTemplate, fqdn, ttl, value)
logf("[INFO] acme: Please create the following TXT record in your DNS zone:")
logf("[INFO] acme: %s", dnsRecord)
logf("[INFO] acme: Press 'Enter' when you are done")
reader := bufio.NewReader(os.Stdin)
_, _ = reader.ReadString('\n')
return nil
}
// RemoveTXTRecord prints instructions for manually removing the TXT record
func (*DNSProviderManual) RemoveTXTRecord(fqdn, value string, ttl int) error {
dnsRecord := fmt.Sprintf(dnsTemplate, fqdn, ttl, value)
logf("[INFO] acme: You can now remove this TXT record from your DNS zone:")
logf("[INFO] acme: %s", dnsRecord)
return nil
}

View file

@ -0,0 +1,84 @@
package acme
import (
"fmt"
"github.com/miekg/dns"
"time"
)
// DNSProviderRFC2136 is an implementation of the DNSProvider interface that
// uses dynamic DNS updates (RFC 2136) to create TXT records on a nameserver.
type DNSProviderRFC2136 struct {
nameserver string
zone string
tsigKey string
tsigSecret string
}
// NewDNSProviderRFC2136 returns a new DNSProviderRFC2136 instance.
// To disable TSIG authentication 'tsigKey' and 'tsigSecret' must be set to the empty string.
// 'nameserver' must be a network address in the the form "host:port". 'zone' must be the fully
// qualified name of the zone.
func NewDNSProviderRFC2136(nameserver, zone, tsigKey, tsigSecret string) (*DNSProviderRFC2136, error) {
d := &DNSProviderRFC2136{
nameserver: nameserver,
zone: zone,
}
if len(tsigKey) > 0 && len(tsigSecret) > 0 {
d.tsigKey = tsigKey
d.tsigSecret = tsigSecret
}
return d, nil
}
// CreateTXTRecord creates a TXT record using the specified parameters
func (r *DNSProviderRFC2136) CreateTXTRecord(fqdn, value string, ttl int) error {
return r.changeRecord("INSERT", fqdn, value, ttl)
}
// RemoveTXTRecord removes the TXT record matching the specified parameters
func (r *DNSProviderRFC2136) RemoveTXTRecord(fqdn, value string, ttl int) error {
return r.changeRecord("REMOVE", fqdn, value, ttl)
}
func (r *DNSProviderRFC2136) changeRecord(action, fqdn, value string, ttl int) error {
// Create RR
rr := new(dns.TXT)
rr.Hdr = dns.RR_Header{Name: fqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(ttl)}
rr.Txt = []string{value}
rrs := make([]dns.RR, 1)
rrs[0] = rr
// Create dynamic update packet
m := new(dns.Msg)
m.SetUpdate(dns.Fqdn(r.zone))
switch action {
case "INSERT":
m.Insert(rrs)
case "REMOVE":
m.Remove(rrs)
default:
return fmt.Errorf("Unexpected action: %s", action)
}
// Setup client
c := new(dns.Client)
c.SingleInflight = true
// TSIG authentication / msg signing
if len(r.tsigKey) > 0 && len(r.tsigSecret) > 0 {
m.SetTsig(dns.Fqdn(r.tsigKey), dns.HmacMD5, 300, time.Now().Unix())
c.TsigSecret = map[string]string{dns.Fqdn(r.tsigKey): r.tsigSecret}
}
// Send the query
reply, _, err := c.Exchange(m, r.nameserver)
if err != nil {
return fmt.Errorf("DNS update failed: %v", err)
}
if reply != nil && reply.Rcode != dns.RcodeSuccess {
return fmt.Errorf("DNS update failed. Server replied: %s", dns.RcodeToString[reply.Rcode])
}
return nil
}

View file

@ -0,0 +1,227 @@
package acme
import (
"bytes"
"github.com/miekg/dns"
"net"
"strings"
"sync"
"testing"
"time"
)
var (
rfc2136TestValue = "so6ZGir4GaZqI11h9UccBB=="
rfc2136TestFqdn = "_acme-challenge.123456789.www.example.com."
rfc2136TestZone = "example.com."
rfc2136TestTTL = 120
rfc2136TestTsigKey = "example.com."
rfc2136TestTsigSecret = "IwBTJx9wrDp4Y1RyC3H0gA=="
)
var reqChan = make(chan *dns.Msg, 10)
func TestRFC2136CanaryLocalTestServer(t *testing.T) {
dns.HandleFunc("example.com.", serverHandlerHello)
defer dns.HandleRemove("example.com.")
server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", false)
if err != nil {
t.Fatalf("Failed to start test server: %v", err)
}
defer server.Shutdown()
c := new(dns.Client)
m := new(dns.Msg)
m.SetQuestion("example.com.", dns.TypeTXT)
r, _, err := c.Exchange(m, addrstr)
if err != nil || len(r.Extra) == 0 {
t.Fatalf("Failed to communicate with test server:", err)
}
txt := r.Extra[0].(*dns.TXT).Txt[0]
if txt != "Hello world" {
t.Error("Expected test server to return 'Hello world' but got: ", txt)
}
}
func TestRFC2136ServerSuccess(t *testing.T) {
dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess)
defer dns.HandleRemove(rfc2136TestZone)
server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", false)
if err != nil {
t.Fatalf("Failed to start test server: %v", err)
}
defer server.Shutdown()
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "")
if err != nil {
t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err)
}
if err := provider.CreateTXTRecord(rfc2136TestFqdn, rfc2136TestValue, rfc2136TestTTL); err != nil {
t.Errorf("Expected CreateTXTRecord() to return no error but the error was -> %v", err)
}
}
func TestRFC2136ServerError(t *testing.T) {
dns.HandleFunc(rfc2136TestZone, serverHandlerReturnErr)
defer dns.HandleRemove(rfc2136TestZone)
server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", false)
if err != nil {
t.Fatalf("Failed to start test server: %v", err)
}
defer server.Shutdown()
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "")
if err != nil {
t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err)
}
if err := provider.CreateTXTRecord(rfc2136TestFqdn, rfc2136TestValue, rfc2136TestTTL); err == nil {
t.Errorf("Expected CreateTXTRecord() to return an error but it did not.")
} else if !strings.Contains(err.Error(), "NOTZONE") {
t.Errorf("Expected CreateTXTRecord() to return an error with the 'NOTZONE' rcode string but it did not.")
}
}
func TestRFC2136TsigClient(t *testing.T) {
dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess)
defer dns.HandleRemove(rfc2136TestZone)
server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", true)
if err != nil {
t.Fatalf("Failed to start test server: %v", err)
}
defer server.Shutdown()
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, rfc2136TestTsigKey, rfc2136TestTsigSecret)
if err != nil {
t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err)
}
if err := provider.CreateTXTRecord(rfc2136TestFqdn, rfc2136TestValue, rfc2136TestTTL); err != nil {
t.Errorf("Expected CreateTXTRecord() to return no error but the error was -> %v", err)
}
}
func TestRFC2136ValidUpdatePacket(t *testing.T) {
dns.HandleFunc(rfc2136TestZone, serverHandlerPassBackRequest)
defer dns.HandleRemove(rfc2136TestZone)
server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", false)
if err != nil {
t.Fatalf("Failed to start test server: %v", err)
}
defer server.Shutdown()
rr := new(dns.TXT)
rr.Hdr = dns.RR_Header{
Name: rfc2136TestFqdn,
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: uint32(rfc2136TestTTL),
}
rr.Txt = []string{rfc2136TestValue}
rrs := make([]dns.RR, 1)
rrs[0] = rr
m := new(dns.Msg)
m.SetUpdate(dns.Fqdn(rfc2136TestZone))
m.Insert(rrs)
expectstr := m.String()
expect, err := m.Pack()
if err != nil {
t.Fatalf("Error packing expect msg: %v", err)
}
provider, err := NewDNSProviderRFC2136(addrstr, rfc2136TestZone, "", "")
if err != nil {
t.Fatalf("Expected NewDNSProviderRFC2136() to return no error but the error was -> %v", err)
}
if err := provider.CreateTXTRecord(rfc2136TestFqdn, rfc2136TestValue, rfc2136TestTTL); err != nil {
t.Errorf("Expected CreateTXTRecord() to return no error but the error was -> %v", err)
}
rcvMsg := <-reqChan
rcvMsg.Id = m.Id
actual, err := rcvMsg.Pack()
if err != nil {
t.Fatalf("Error packing actual msg: %v", err)
}
if !bytes.Equal(actual, expect) {
tmp := new(dns.Msg)
if err := tmp.Unpack(actual); err != nil {
t.Fatalf("Error unpacking actual msg: %v", err)
}
t.Errorf("Expected msg:\n%s", expectstr)
t.Errorf("Actual msg:\n%v", tmp)
}
}
func runLocalDNSTestServer(listenAddr string, tsig bool) (*dns.Server, string, error) {
pc, err := net.ListenPacket("udp", listenAddr)
if err != nil {
return nil, "", err
}
server := &dns.Server{PacketConn: pc, ReadTimeout: time.Hour, WriteTimeout: time.Hour}
if tsig {
server.TsigSecret = map[string]string{rfc2136TestTsigKey: rfc2136TestTsigSecret}
}
waitLock := sync.Mutex{}
waitLock.Lock()
server.NotifyStartedFunc = waitLock.Unlock
go func() {
server.ActivateAndServe()
pc.Close()
}()
waitLock.Lock()
return server, pc.LocalAddr().String(), nil
}
func serverHandlerHello(w dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg)
m.SetReply(req)
m.Extra = make([]dns.RR, 1)
m.Extra[0] = &dns.TXT{
Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 0},
Txt: []string{"Hello world"},
}
w.WriteMsg(m)
}
func serverHandlerReturnSuccess(w dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg)
m.SetReply(req)
if t := req.IsTsig(); t != nil {
if w.TsigStatus() == nil {
// Validated
m.SetTsig(rfc2136TestZone, dns.HmacMD5, 300, time.Now().Unix())
}
}
w.WriteMsg(m)
}
func serverHandlerReturnErr(w dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg)
m.SetRcode(req, dns.RcodeNotZone)
w.WriteMsg(m)
}
func serverHandlerPassBackRequest(w dns.ResponseWriter, req *dns.Msg) {
m := new(dns.Msg)
m.SetReply(req)
if t := req.IsTsig(); t != nil {
if w.TsigStatus() == nil {
// Validated
m.SetTsig(rfc2136TestZone, dns.HmacMD5, 300, time.Now().Unix())
}
}
w.WriteMsg(m)
reqChan <- req
}

View file

@ -0,0 +1,93 @@
package acme
import (
"fmt"
"github.com/mitchellh/goamz/aws"
"github.com/mitchellh/goamz/route53"
"math"
"strings"
)
// DNSProviderRoute53 is an implementation of the DNSProvider interface
type DNSProviderRoute53 struct {
client *route53.Route53
}
// NewDNSProviderRoute53 returns a DNSProviderRoute53 instance with a configured route53 client.
// Authentication is either done using the passed credentials or - when empty -
// using the environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
func NewDNSProviderRoute53(awsAccessKey, awsSecretKey, awsRegionName string) (*DNSProviderRoute53, error) {
region, ok := aws.Regions[awsRegionName]
if !ok {
return nil, fmt.Errorf("Invalid AWS region name %s", awsRegionName)
}
var auth aws.Auth
// First try passed in credentials
if awsAccessKey != "" && awsSecretKey != "" {
auth = aws.Auth{awsAccessKey, awsSecretKey, ""}
} else {
// try getting credentials from environment
envAuth, err := aws.EnvAuth()
if err != nil {
return nil, fmt.Errorf("AWS credentials missing")
}
auth = envAuth
}
client := route53.New(auth, region)
return &DNSProviderRoute53{client: client}, nil
}
// CreateTXTRecord creates a TXT record using the specified parameters
func (r *DNSProviderRoute53) CreateTXTRecord(fqdn, value string, ttl int) error {
return r.changeRecord("UPSERT", fqdn, value, ttl)
}
// RemoveTXTRecord removes the TXT record matching the specified parameters
func (r *DNSProviderRoute53) RemoveTXTRecord(fqdn, value string, ttl int) error {
return r.changeRecord("DELETE", fqdn, value, ttl)
}
func (r *DNSProviderRoute53) changeRecord(action, fqdn, value string, ttl int) error {
hostedZoneID, err := r.getHostedZoneID(fqdn)
if err != nil {
return err
}
recordSet := newTXTRecordSet(fqdn, value, ttl)
update := route53.Change{action, recordSet}
changes := []route53.Change{update}
req := route53.ChangeResourceRecordSetsRequest{Comment: "Created by Lego", Changes: changes}
_, err = r.client.ChangeResourceRecordSets(hostedZoneID, &req)
return err
}
func (r *DNSProviderRoute53) getHostedZoneID(fqdn string) (string, error) {
zoneResp, err := r.client.ListHostedZones("", math.MaxInt64)
if err != nil {
return "", err
}
var hostedZone route53.HostedZone
for _, zone := range zoneResp.HostedZones {
//if strings.HasSuffix(domain, strings.Trim(zone.Name, ".")) {
if strings.HasSuffix(fqdn, zone.Name) {
if len(zone.Name) > len(hostedZone.Name) {
hostedZone = zone
}
}
}
if hostedZone.ID == "" {
return "", fmt.Errorf("No Route53 zone found for domain %s", fqdn)
}
return hostedZone.ID, nil
}
func newTXTRecordSet(fqdn, value string, ttl int) route53.ResourceRecordSet {
return route53.ResourceRecordSet{
Name: fqdn,
Type: "TXT",
Records: []string{value},
TTL: ttl,
}
}

View file

@ -0,0 +1,126 @@
package acme
import (
"os"
"testing"
"github.com/mitchellh/goamz/aws"
"github.com/mitchellh/goamz/route53"
"github.com/mitchellh/goamz/testutil"
"github.com/stretchr/testify/assert"
)
var (
route53Secret string
route53Key string
testServer *testutil.HTTPServer
)
var ChangeResourceRecordSetsAnswer = `<?xml version="1.0" encoding="UTF-8"?>
<ChangeResourceRecordSetsResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
<ChangeInfo>
<Id>/change/asdf</Id>
<Status>PENDING</Status>
<SubmittedAt>2014</SubmittedAt>
</ChangeInfo>
</ChangeResourceRecordSetsResponse>`
var ListHostedZonesAnswer = `<?xml version="1.0" encoding="utf-8"?>
<ListHostedZonesResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
<HostedZones>
<HostedZone>
<Id>/hostedzone/Z2K123214213123</Id>
<Name>example.com.</Name>
<CallerReference>D2224C5B-684A-DB4A-BB9A-E09E3BAFEA7A</CallerReference>
<Config>
<Comment>Test comment</Comment>
</Config>
<ResourceRecordSetCount>10</ResourceRecordSetCount>
</HostedZone>
<HostedZone>
<Id>/hostedzone/ZLT12321321124</Id>
<Name>sub.example.com.</Name>
<CallerReference>A970F076-FCB1-D959-B395-96474CC84EB8</CallerReference>
<Config>
<Comment>Test comment for subdomain host</Comment>
</Config>
<ResourceRecordSetCount>4</ResourceRecordSetCount>
</HostedZone>
</HostedZones>
<IsTruncated>false</IsTruncated>
<MaxItems>100</MaxItems>
</ListHostedZonesResponse>`
var serverResponseMap = testutil.ResponseMap{
"/2013-04-01/hostedzone/": testutil.Response{200, nil, ListHostedZonesAnswer},
"/2013-04-01/hostedzone/Z2K123214213123/rrset": testutil.Response{200, nil, ChangeResourceRecordSetsAnswer},
}
func init() {
route53Key = os.Getenv("AWS_ACCESS_KEY_ID")
route53Secret = os.Getenv("AWS_SECRET_ACCESS_KEY")
testServer = testutil.NewHTTPServer()
testServer.Start()
}
func restoreRoute53Env() {
os.Setenv("AWS_ACCESS_KEY_ID", route53Key)
os.Setenv("AWS_SECRET_ACCESS_KEY", route53Secret)
}
func makeRoute53TestServer() *testutil.HTTPServer {
testServer.Flush()
return testServer
}
func makeRoute53Provider(server *testutil.HTTPServer) *DNSProviderRoute53 {
auth := aws.Auth{"abc", "123", ""}
client := route53.NewWithClient(auth, aws.Region{Route53Endpoint: server.URL}, testutil.DefaultClient)
return &DNSProviderRoute53{client: client}
}
func TestNewDNSProviderRoute53Valid(t *testing.T) {
os.Setenv("AWS_ACCESS_KEY_ID", "")
os.Setenv("AWS_SECRET_ACCESS_KEY", "")
_, err := NewDNSProviderRoute53("123", "123", "us-east-1")
assert.NoError(t, err)
restoreRoute53Env()
}
func TestNewDNSProviderRoute53ValidEnv(t *testing.T) {
os.Setenv("AWS_ACCESS_KEY_ID", "123")
os.Setenv("AWS_SECRET_ACCESS_KEY", "123")
_, err := NewDNSProviderRoute53("", "", "us-east-1")
assert.NoError(t, err)
restoreRoute53Env()
}
func TestNewDNSProviderRoute53MissingAuthErr(t *testing.T) {
os.Setenv("AWS_ACCESS_KEY_ID", "")
os.Setenv("AWS_SECRET_ACCESS_KEY", "")
_, err := NewDNSProviderRoute53("", "", "us-east-1")
assert.EqualError(t, err, "AWS credentials missing")
restoreRoute53Env()
}
func TestNewDNSProviderRoute53InvalidRegionErr(t *testing.T) {
_, err := NewDNSProviderRoute53("123", "123", "us-east-3")
assert.EqualError(t, err, "Invalid AWS region name us-east-3")
}
func TestRoute53CreateTXTRecord(t *testing.T) {
assert := assert.New(t)
testServer := makeRoute53TestServer()
provider := makeRoute53Provider(testServer)
testServer.ResponseMap(2, serverResponseMap)
err := provider.CreateTXTRecord("_acme-challenge.123.example.com.", "123456d==", 120)
assert.NoError(err, "Expected CreateTXTRecord to return no error")
httpReqs := testServer.WaitRequests(2)
httpReq := httpReqs[1]
assert.Equal("/2013-04-01/hostedzone/Z2K123214213123/rrset", httpReq.URL.Path,
"Expected CreateTXTRecord to select the correct hostedzone")
}

View file

@ -0,0 +1,39 @@
package acme
import (
"bufio"
"crypto/rsa"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
)
func TestDNSValidServerResponse(t *testing.T) {
preCheckDNS = func() bool {
return false
}
privKey, _ := generatePrivateKey(rsakey, 512)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Replay-Nonce", "12345")
w.Write([]byte("{\"type\":\"dns01\",\"status\":\"valid\",\"uri\":\"http://some.url\",\"token\":\"http8\"}"))
}))
manualProvider, _ := NewDNSProviderManual()
jws := &jws{privKey: privKey.(*rsa.PrivateKey), directoryURL: ts.URL}
solver := &dnsChallenge{jws: jws, provider: manualProvider}
clientChallenge := challenge{Type: "dns01", Status: "pending", URI: ts.URL, Token: "http8"}
go func() {
time.Sleep(time.Second * 2)
f := bufio.NewWriter(os.Stdout)
defer f.Flush()
f.WriteString("\n")
}()
if err := solver.Solve(clientChallenge, "example.com"); err != nil {
t.Errorf("VALID: Expected Solve to return no error but the error was -> %v", err)
}
}