From ee8d34c16fa02d779afcce5e98dcfc65a08c43f5 Mon Sep 17 00:00:00 2001 From: James Munnelly Date: Mon, 7 Aug 2017 18:28:35 +0100 Subject: [PATCH] Add cloudflare dns01 provider --- pkg/apis/certmanager/types.go | 10 +- pkg/apis/certmanager/v1alpha1/types.go | 10 +- pkg/issuer/acme/dns/cloudflare/cloudflare.go | 224 ++++++++++++++++++ .../acme/dns/cloudflare/cloudflare_test.go | 80 +++++++ pkg/issuer/acme/dns/dns.go | 14 ++ 5 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 pkg/issuer/acme/dns/cloudflare/cloudflare.go create mode 100644 pkg/issuer/acme/dns/cloudflare/cloudflare_test.go diff --git a/pkg/apis/certmanager/types.go b/pkg/apis/certmanager/types.go index 611007354..dc97e1c5a 100644 --- a/pkg/apis/certmanager/types.go +++ b/pkg/apis/certmanager/types.go @@ -77,7 +77,8 @@ type ACMEIssuerDNS01Config struct { type ACMEIssuerDNS01Provider struct { Name string - CloudDNS *ACMEIssuerDNS01ProviderCloudDNS + CloudDNS *ACMEIssuerDNS01ProviderCloudDNS + Cloudflare *ACMEIssuerDNS01ProviderCloudflare } // ACMEIssuerDNS01ProviderCloudDNS is a structure containing the DNS @@ -87,6 +88,13 @@ type ACMEIssuerDNS01ProviderCloudDNS struct { Project string } +// ACMEIssuerDNS01ProviderCloudflare is a structure containing the DNS +// configuration for Cloudflare +type ACMEIssuerDNS01ProviderCloudflare struct { + Email string + APIKey SecretKeySelector +} + // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // Certificate is a type to represent a Certificate from ACME diff --git a/pkg/apis/certmanager/v1alpha1/types.go b/pkg/apis/certmanager/v1alpha1/types.go index 5c2ce231a..616f3d305 100644 --- a/pkg/apis/certmanager/v1alpha1/types.go +++ b/pkg/apis/certmanager/v1alpha1/types.go @@ -102,7 +102,8 @@ func (a *ACMEIssuerDNS01Config) Provider(name string) (*ACMEIssuerDNS01Provider, type ACMEIssuerDNS01Provider struct { Name string `json:"name"` - CloudDNS *ACMEIssuerDNS01ProviderCloudDNS `json:"clouddns,omitempty"` + CloudDNS *ACMEIssuerDNS01ProviderCloudDNS `json:"clouddns,omitempty"` + Cloudflare *ACMEIssuerDNS01ProviderCloudflare `json:"cloudflare,omitempty"` } // ACMEIssuerDNS01ProviderCloudDNS is a structure containing the DNS @@ -112,6 +113,13 @@ type ACMEIssuerDNS01ProviderCloudDNS struct { Project string `json:"project"` } +// ACMEIssuerDNS01ProviderCloudflare is a structure containing the DNS +// configuration for Cloudflare +type ACMEIssuerDNS01ProviderCloudflare struct { + Email string `json:"email"` + APIKey SecretKeySelector `json:"apiKey"` +} + // +genclient=true // +k8s:openapi-gen=true // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/pkg/issuer/acme/dns/cloudflare/cloudflare.go b/pkg/issuer/acme/dns/cloudflare/cloudflare.go new file mode 100644 index 000000000..348ce7b7d --- /dev/null +++ b/pkg/issuer/acme/dns/cloudflare/cloudflare.go @@ -0,0 +1,224 @@ +// Package cloudflare implements a DNS provider for solving the DNS-01 +// challenge using cloudflare DNS. +package cloudflare + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/jetstack-experimental/cert-manager/pkg/issuer/acme/dns/util" +) + +// CloudFlareAPIURL represents the API endpoint to call. +// TODO: Unexport? +const CloudFlareAPIURL = "https://api.cloudflare.com/client/v4" + +// DNSProvider is an implementation of the acme.ChallengeProvider interface +type DNSProvider struct { + authEmail string + authKey string +} + +// NewDNSProvider returns a DNSProvider instance configured for cloudflare. +// Credentials must be passed in the environment variables: CLOUDFLARE_EMAIL +// and CLOUDFLARE_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + email := os.Getenv("CLOUDFLARE_EMAIL") + key := os.Getenv("CLOUDFLARE_API_KEY") + return NewDNSProviderCredentials(email, key) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for cloudflare. +func NewDNSProviderCredentials(email, key string) (*DNSProvider, error) { + if email == "" || key == "" { + return nil, fmt.Errorf("CloudFlare credentials missing") + } + + return &DNSProvider{ + authEmail: email, + authKey: key, + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS +// propagation. Adjusting here to cope with spikes in propagation times. +func (c *DNSProvider) Timeout() (timeout, interval time.Duration) { + return 120 * time.Second, 2 * time.Second +} + +// Present creates a TXT record to fulfil the dns-01 challenge +func (c *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, _ := util.DNS01Record(domain, keyAuth) + + zoneID, err := c.getHostedZoneID(fqdn) + if err != nil { + return err + } + + rec := cloudFlareRecord{ + Type: "TXT", + Name: util.UnFqdn(fqdn), + Content: value, + TTL: 120, + } + + body, err := json.Marshal(rec) + if err != nil { + return err + } + + _, err = c.makeRequest("POST", fmt.Sprintf("/zones/%s/dns_records", zoneID), bytes.NewReader(body)) + if err != nil { + return err + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters +func (c *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := util.DNS01Record(domain, keyAuth) + + record, err := c.findTxtRecord(fqdn) + if err != nil { + return err + } + + _, err = c.makeRequest("DELETE", fmt.Sprintf("/zones/%s/dns_records/%s", record.ZoneID, record.ID), nil) + if err != nil { + return err + } + + return nil +} + +func (c *DNSProvider) getHostedZoneID(fqdn string) (string, error) { + // HostedZone represents a CloudFlare DNS zone + type HostedZone struct { + ID string `json:"id"` + Name string `json:"name"` + } + + authZone, err := util.FindZoneByFqdn(fqdn, util.RecursiveNameservers) + if err != nil { + return "", err + } + + result, err := c.makeRequest("GET", "/zones?name="+util.UnFqdn(authZone), nil) + if err != nil { + return "", err + } + + var hostedZone []HostedZone + err = json.Unmarshal(result, &hostedZone) + if err != nil { + return "", err + } + + if len(hostedZone) != 1 { + return "", fmt.Errorf("Zone %s not found in CloudFlare for domain %s", authZone, fqdn) + } + + return hostedZone[0].ID, nil +} + +func (c *DNSProvider) findTxtRecord(fqdn string) (*cloudFlareRecord, error) { + zoneID, err := c.getHostedZoneID(fqdn) + if err != nil { + return nil, err + } + + result, err := c.makeRequest( + "GET", + fmt.Sprintf("/zones/%s/dns_records?per_page=1000&type=TXT&name=%s", zoneID, util.UnFqdn(fqdn)), + nil, + ) + if err != nil { + return nil, err + } + + var records []cloudFlareRecord + err = json.Unmarshal(result, &records) + if err != nil { + return nil, err + } + + for _, rec := range records { + if rec.Name == util.UnFqdn(fqdn) { + return &rec, nil + } + } + + return nil, fmt.Errorf("No existing record found for %s", fqdn) +} + +func (c *DNSProvider) makeRequest(method, uri string, body io.Reader) (json.RawMessage, error) { + // APIError contains error details for failed requests + type APIError struct { + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` + ErrorChain []APIError `json:"error_chain,omitempty"` + } + + // APIResponse represents a response from CloudFlare API + type APIResponse struct { + Success bool `json:"success"` + Errors []*APIError `json:"errors"` + Result json.RawMessage `json:"result"` + } + + req, err := http.NewRequest(method, fmt.Sprintf("%s%s", CloudFlareAPIURL, uri), body) + if err != nil { + return nil, err + } + + req.Header.Set("X-Auth-Email", c.authEmail) + req.Header.Set("X-Auth-Key", c.authKey) + //req.Header.Set("User-Agent", userAgent()) + + client := http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("Error querying Cloudflare API -> %v", err) + } + + defer resp.Body.Close() + + var r APIResponse + err = json.NewDecoder(resp.Body).Decode(&r) + if err != nil { + return nil, err + } + + if !r.Success { + if len(r.Errors) > 0 { + errStr := "" + for _, apiErr := range r.Errors { + errStr += fmt.Sprintf("\t Error: %d: %s", apiErr.Code, apiErr.Message) + for _, chainErr := range apiErr.ErrorChain { + errStr += fmt.Sprintf("<- %d: %s", chainErr.Code, chainErr.Message) + } + } + return nil, fmt.Errorf("Cloudflare API Error \n%s", errStr) + } + return nil, fmt.Errorf("Cloudflare API error") + } + + return r.Result, nil +} + +// cloudFlareRecord represents a CloudFlare DNS record +type cloudFlareRecord struct { + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + ID string `json:"id,omitempty"` + TTL int `json:"ttl,omitempty"` + ZoneID string `json:"zone_id,omitempty"` +} diff --git a/pkg/issuer/acme/dns/cloudflare/cloudflare_test.go b/pkg/issuer/acme/dns/cloudflare/cloudflare_test.go new file mode 100644 index 000000000..19b5a40b9 --- /dev/null +++ b/pkg/issuer/acme/dns/cloudflare/cloudflare_test.go @@ -0,0 +1,80 @@ +package cloudflare + +import ( + "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 TestNewDNSProviderValid(t *testing.T) { + os.Setenv("CLOUDFLARE_EMAIL", "") + os.Setenv("CLOUDFLARE_API_KEY", "") + _, err := NewDNSProviderCredentials("123", "123") + assert.NoError(t, err) + restoreCloudFlareEnv() +} + +func TestNewDNSProviderValidEnv(t *testing.T) { + os.Setenv("CLOUDFLARE_EMAIL", "test@example.com") + os.Setenv("CLOUDFLARE_API_KEY", "123") + _, err := NewDNSProvider() + assert.NoError(t, err) + restoreCloudFlareEnv() +} + +func TestNewDNSProviderMissingCredErr(t *testing.T) { + os.Setenv("CLOUDFLARE_EMAIL", "") + os.Setenv("CLOUDFLARE_API_KEY", "") + _, err := NewDNSProvider() + assert.EqualError(t, err, "CloudFlare credentials missing") + restoreCloudFlareEnv() +} + +func TestCloudFlarePresent(t *testing.T) { + if !cflareLiveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProviderCredentials(cflareEmail, cflareAPIKey) + assert.NoError(t, err) + + err = provider.Present(cflareDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestCloudFlareCleanUp(t *testing.T) { + if !cflareLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 2) + + provider, err := NewDNSProviderCredentials(cflareEmail, cflareAPIKey) + assert.NoError(t, err) + + err = provider.CleanUp(cflareDomain, "", "123d==") + assert.NoError(t, err) +} diff --git a/pkg/issuer/acme/dns/dns.go b/pkg/issuer/acme/dns/dns.go index 5fd0c9502..a5c52f852 100644 --- a/pkg/issuer/acme/dns/dns.go +++ b/pkg/issuer/acme/dns/dns.go @@ -11,6 +11,7 @@ import ( "github.com/jetstack-experimental/cert-manager/pkg/apis/certmanager/v1alpha1" "github.com/jetstack-experimental/cert-manager/pkg/issuer/acme/dns/clouddns" + "github.com/jetstack-experimental/cert-manager/pkg/issuer/acme/dns/cloudflare" "github.com/jetstack-experimental/cert-manager/pkg/issuer/acme/dns/util" ) @@ -118,6 +119,19 @@ func (s *Solver) solverFor(crt *v1alpha1.Certificate, domain string) (solver, er if err != nil { return nil, fmt.Errorf("error instantiating google clouddns challenge solver: %s", err.Error()) } + case providerConfig.Cloudflare != nil: + apiKeySecret, err := s.secretLister.Secrets(s.issuer.Namespace).Get(providerConfig.Cloudflare.APIKey.Name) + if err != nil { + return nil, fmt.Errorf("error getting clouddns service account: %s", err.Error()) + } + + email := providerConfig.Cloudflare.Email + apiKey := string(apiKeySecret.Data[providerConfig.Cloudflare.APIKey.Key]) + + impl, err = cloudflare.NewDNSProviderCredentials(email, apiKey) + if err != nil { + return nil, fmt.Errorf("error instantiating cloudflare challenge solver: %s", err.Error()) + } default: return nil, fmt.Errorf("no dns provider config specified for domain '%s'", domain) }