Permalink
Browse files

Modular DNS challenge

- Manual provider
- Dynamic DNS Update provider (RFC2136)
- Route53 provider
- CloudFlare provider
  • Loading branch information...
Jan Broer
Jan Broer committed Dec 3, 2015
1 parent cce3d79 commit 666698cea3e0108a6a34d89cb2cfb4f2d926e6b1
View
@@ -1,24 +1,26 @@
package acme
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"time"
)
const (
dnsTemplate = "_acme-challenge.%s. 300 IN TXT \"%s\""
)
// 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
jws *jws
provider DNSProvider
}
func (s *dnsChallenge) Solve(chlng challenge, domain string) error {
@@ -36,11 +38,10 @@ func (s *dnsChallenge) Solve(chlng challenge, domain string) error {
// of the base64 encoding mentioned by the spec. Fix this if either the spec or boulder changes!
keyAuthSha := hex.EncodeToString(keyAuthShaBytes[:sha256.Size])
dnsRecord := fmt.Sprintf(dnsTemplate, domain, keyAuthSha)
logf("[DEBUG] acme: DNS Record: %s", dnsRecord)
reader := bufio.NewReader(os.Stdin)
_, _ = reader.ReadString('\n')
fqdn := fmt.Sprintf("_acme-challenge.%s.", domain)
if err = s.provider.CreateTXTRecord(fqdn, keyAuthSha, 120); err != nil {
return err
}
jsonBytes, err := json.Marshal(challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})
if err != nil {
@@ -83,5 +84,9 @@ Loop:
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
}
@@ -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
}
@@ -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)
}
@@ -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
}
Oops, something went wrong.

0 comments on commit 666698c

Please sign in to comment.