From 59e905cbcc42ada5225bda6b1280cb0854b94b67 Mon Sep 17 00:00:00 2001 From: Zadkiel Aharonian Date: Fri, 19 Oct 2018 17:10:40 +0200 Subject: [PATCH] Add ACME DigitalOcean DNS01 provider Signed-off-by: Zadkiel Aharonian --- Gopkg.toml | 4 + .../issuers/acme/dns01/digitalocean.rst | 14 ++ docs/reference/issuers/acme/dns01/index.rst | 3 +- pkg/apis/certmanager/v1alpha1/types_issuer.go | 21 ++- pkg/apis/certmanager/validation/issuer.go | 9 + .../acme/dns/digitalocean/digitalocean.go | 166 ++++++++++++++++++ .../dns/digitalocean/digitalocean_test.go | 95 ++++++++++ pkg/issuer/acme/dns/dns.go | 27 ++- pkg/issuer/acme/dns/dns_test.go | 54 ++++++ pkg/issuer/acme/dns/util_test.go | 5 + 10 files changed, 384 insertions(+), 14 deletions(-) create mode 100644 docs/reference/issuers/acme/dns01/digitalocean.rst create mode 100644 pkg/issuer/acme/dns/digitalocean/digitalocean.go create mode 100644 pkg/issuer/acme/dns/digitalocean/digitalocean_test.go diff --git a/Gopkg.toml b/Gopkg.toml index 487903e1a..d8a6820cc 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -36,6 +36,10 @@ required = [ name = "github.com/Azure/azure-sdk-for-go" version = "v12.0.0-beta" +[[constraint]] + name = "github.com/digitalocean/godo" + version = "v1.6.0" + [[constraint]] name = "github.com/openshift/generic-admission-server" revision = "76d182e57ce628bbf6eb266a7d26cf6c52adf551" diff --git a/docs/reference/issuers/acme/dns01/digitalocean.rst b/docs/reference/issuers/acme/dns01/digitalocean.rst new file mode 100644 index 000000000..9026dd3e8 --- /dev/null +++ b/docs/reference/issuers/acme/dns01/digitalocean.rst @@ -0,0 +1,14 @@ +========================= +DigitalOcean +========================= + +This provider uses a Kubernetes ``Secret`` Resource to work. In the +following example, the secret will have to be named ``digitalocean-dns`` +and have a subkey ``access-token`` with the token in it. + +.. code-block:: yaml + + digitalocean: + tokenSecretRef: + name: digitalocean-dns + key: access-token \ No newline at end of file diff --git a/docs/reference/issuers/acme/dns01/index.rst b/docs/reference/issuers/acme/dns01/index.rst index fd77ebacd..73c3b0d77 100644 --- a/docs/reference/issuers/acme/dns01/index.rst +++ b/docs/reference/issuers/acme/dns01/index.rst @@ -67,4 +67,5 @@ and provider specific notes regarding their usage. azuredns cloudflare google - route53 \ No newline at end of file + route53 + digitalocean \ No newline at end of file diff --git a/pkg/apis/certmanager/v1alpha1/types_issuer.go b/pkg/apis/certmanager/v1alpha1/types_issuer.go index bccf1ba2d..75ed02f1f 100644 --- a/pkg/apis/certmanager/v1alpha1/types_issuer.go +++ b/pkg/apis/certmanager/v1alpha1/types_issuer.go @@ -155,13 +155,14 @@ type ACMEIssuerDNS01Config struct { type ACMEIssuerDNS01Provider struct { Name string `json:"name"` - Akamai *ACMEIssuerDNS01ProviderAkamai `json:"akamai,omitempty"` - CloudDNS *ACMEIssuerDNS01ProviderCloudDNS `json:"clouddns,omitempty"` - Cloudflare *ACMEIssuerDNS01ProviderCloudflare `json:"cloudflare,omitempty"` - Route53 *ACMEIssuerDNS01ProviderRoute53 `json:"route53,omitempty"` - AzureDNS *ACMEIssuerDNS01ProviderAzureDNS `json:"azuredns,omitempty"` - AcmeDNS *ACMEIssuerDNS01ProviderAcmeDNS `json:"acmedns,omitempty"` - RFC2136 *ACMEIssuerDNS01ProviderRFC2136 `json:"rfc2136,omitempty"` + Akamai *ACMEIssuerDNS01ProviderAkamai `json:"akamai,omitempty"` + CloudDNS *ACMEIssuerDNS01ProviderCloudDNS `json:"clouddns,omitempty"` + Cloudflare *ACMEIssuerDNS01ProviderCloudflare `json:"cloudflare,omitempty"` + Route53 *ACMEIssuerDNS01ProviderRoute53 `json:"route53,omitempty"` + AzureDNS *ACMEIssuerDNS01ProviderAzureDNS `json:"azuredns,omitempty"` + DigitalOcean *ACMEIssuerDNS01ProviderDigitalOcean `json:"digitalocean,omitempty"` + AcmeDNS *ACMEIssuerDNS01ProviderAcmeDNS `json:"acmedns,omitempty"` + RFC2136 *ACMEIssuerDNS01ProviderRFC2136 `json:"rfc2136,omitempty"` } // ACMEIssuerDNS01ProviderAkamai is a structure containing the DNS @@ -187,6 +188,12 @@ type ACMEIssuerDNS01ProviderCloudflare struct { APIKey SecretKeySelector `json:"apiKeySecretRef"` } +// ACMEIssuerDNS01ProviderDigitalOcean is a structure containing the DNS +// configuration for DigitalOcean Domains +type ACMEIssuerDNS01ProviderDigitalOcean struct { + Token SecretKeySelector `json:"tokenSecretRef"` +} + // ACMEIssuerDNS01ProviderRoute53 is a structure containing the Route 53 // configuration for AWS type ACMEIssuerDNS01ProviderRoute53 struct { diff --git a/pkg/apis/certmanager/validation/issuer.go b/pkg/apis/certmanager/validation/issuer.go index 36accad53..4dd85c1ae 100644 --- a/pkg/apis/certmanager/validation/issuer.go +++ b/pkg/apis/certmanager/validation/issuer.go @@ -243,6 +243,15 @@ func ValidateACMEIssuerDNS01Config(iss *v1alpha1.ACMEIssuerDNS01Config, fldPath el = append(el, field.Required(fldPath.Child("acmedns", "host"), "")) } } + + if p.DigitalOcean != nil { + if numProviders > 0 { + el = append(el, field.Forbidden(fldPath.Child("digitalocean"), "may not specify more than one provider type")) + } else { + numProviders++ + el = append(el, ValidateSecretKeySelector(&p.DigitalOcean.Token, fldPath.Child("digitalocean", "tokenSecretRef"))...) + } + } if p.RFC2136 != nil { if numProviders > 0 { el = append(el, field.Forbidden(fldPath.Child("rfc2136"), "may not specify more than one provider type")) diff --git a/pkg/issuer/acme/dns/digitalocean/digitalocean.go b/pkg/issuer/acme/dns/digitalocean/digitalocean.go new file mode 100644 index 000000000..81a0c748f --- /dev/null +++ b/pkg/issuer/acme/dns/digitalocean/digitalocean.go @@ -0,0 +1,166 @@ +/* +Copyright 2018 The Jetstack cert-manager contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package digitalocean implements a DNS provider for solving the DNS-01 +// challenge using digitalocean DNS. +package digitalocean + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/digitalocean/godo" + "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/util" + "golang.org/x/oauth2" +) + +// DNSProvider is an implementation of the acme.ChallengeProvider interface +type DNSProvider struct { + dns01Nameservers []string + client *godo.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for digitalocean. +// The access token must be passed in the environment variable DIGITALOCEAN_TOKEN +func NewDNSProvider(dns01Nameservers []string) (*DNSProvider, error) { + token := os.Getenv("DIGITALOCEAN_TOKEN") + return NewDNSProviderCredentials(token, dns01Nameservers) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for digitalocean. +func NewDNSProviderCredentials(token string, dns01Nameservers []string) (*DNSProvider, error) { + if token == "" { + return nil, fmt.Errorf("DigitalOcean token missing") + } + + c := oauth2.NewClient( + context.Background(), + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}), + ) + + return &DNSProvider{ + dns01Nameservers: dns01Nameservers, + client: godo.NewClient(c), + }, 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, ttl, err := util.DNS01Record(domain, keyAuth, c.dns01Nameservers) + if err != nil { + return err + } + + // if DigitalOcean does not have this zone then we will find out later + zoneName, err := util.FindZoneByFqdn(fqdn, c.dns01Nameservers) + if err != nil { + return err + } + + // check if the record has already been created + records, err := c.findTxtRecord(fqdn) + for _, record := range records { + if record.Type == "TXT" && record.Data == keyAuth { + return nil + } + + } + + createRequest := &godo.DomainRecordEditRequest{ + Type: "TXT", + Name: fqdn, + Data: value, + TTL: ttl, + } + + _, _, err = c.client.Domains.CreateRecord( + context.Background(), + util.UnFqdn(zoneName), + createRequest, + ) + + 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, _, _, err := util.DNS01Record(domain, keyAuth, c.dns01Nameservers) + if err != nil { + return err + } + + zoneName, err := util.FindZoneByFqdn(fqdn, c.dns01Nameservers) + + records, err := c.findTxtRecord(fqdn) + if err != nil { + return err + } + + for _, record := range records { + _, err = c.client.Domains.DeleteRecord(context.Background(), util.UnFqdn(zoneName), record.ID) + + if err != nil { + return err + } + } + + return nil +} + +func (c *DNSProvider) findTxtRecord(fqdn string) ([]godo.DomainRecord, error) { + + zoneName, err := util.FindZoneByFqdn(fqdn, c.dns01Nameservers) + if err != nil { + return nil, err + } + + allRecords, _, err := c.client.Domains.Records( + context.Background(), + util.UnFqdn(zoneName), + nil, + ) + + var records []godo.DomainRecord + + // The record Name doesn't contain the zoneName, so + // lets remove it before filtering the array of record + targetName := fqdn + if strings.HasSuffix(fqdn, zoneName) { + targetName = fqdn[:len(fqdn)-len(zoneName)] + } + + for _, record := range allRecords { + if util.ToFqdn(record.Name) == targetName { + records = append(records, record) + } + } + + return records, err +} diff --git a/pkg/issuer/acme/dns/digitalocean/digitalocean_test.go b/pkg/issuer/acme/dns/digitalocean/digitalocean_test.go new file mode 100644 index 000000000..05fe8082b --- /dev/null +++ b/pkg/issuer/acme/dns/digitalocean/digitalocean_test.go @@ -0,0 +1,95 @@ +/* +Copyright 2018 The Jetstack cert-manager contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package digitalocean + +import ( + "os" + "testing" + "time" + + "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/util" + "github.com/stretchr/testify/assert" +) + +var ( + doLiveTest bool + doToken string + doDomain string +) + +func init() { + doToken = os.Getenv("DIGITALOCEAN_TOKEN") + doDomain = os.Getenv("DIGITALOCEAN_DOMAIN") + if len(doToken) > 0 && len(doDomain) > 0 { + doLiveTest = true + } +} + +func restoreEnv() { + os.Setenv("DIGITALOCEAN_TOKEN", doToken) +} + +func TestNewDNSProviderValid(t *testing.T) { + os.Setenv("DIGITALOCEAN_TOKEN", "") + _, err := NewDNSProviderCredentials("123", util.RecursiveNameservers) + assert.NoError(t, err) + restoreEnv() +} + +func TestNewDNSProviderValidEnv(t *testing.T) { + os.Setenv("DIGITALOCEAN_TOKEN", "123") + _, err := NewDNSProvider(util.RecursiveNameservers) + assert.NoError(t, err) + restoreEnv() +} + +func TestNewDNSProviderMissingCredErr(t *testing.T) { + os.Setenv("DIGITALOCEAN_TOKEN", "") + _, err := NewDNSProvider(util.RecursiveNameservers) + assert.EqualError(t, err, "DigitalOcean token missing") + restoreEnv() +} + +func TestDigitalOceanPresent(t *testing.T) { + if !doLiveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProviderCredentials(doToken, util.RecursiveNameservers) + assert.NoError(t, err) + + err = provider.Present(doDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestDigitalOceanCleanUp(t *testing.T) { + if !doLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 2) + + provider, err := NewDNSProviderCredentials(doToken, util.RecursiveNameservers) + assert.NoError(t, err) + + err = provider.CleanUp(doDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestDigitalOceanSolveForProvider(t *testing.T) { + +} diff --git a/pkg/issuer/acme/dns/dns.go b/pkg/issuer/acme/dns/dns.go index 6234c3102..fd50b1429 100644 --- a/pkg/issuer/acme/dns/dns.go +++ b/pkg/issuer/acme/dns/dns.go @@ -33,6 +33,7 @@ import ( "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/azuredns" "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/clouddns" "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/cloudflare" + "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/digitalocean" "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/rfc2136" "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/route53" "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/util" @@ -52,12 +53,13 @@ type solver interface { // It is useful for mocking out a given provider since an alternate set of // constructors may be set. type dnsProviderConstructors struct { - cloudDNS func(project string, serviceAccount []byte, dns01Nameservers []string, ambient bool) (*clouddns.DNSProvider, error) - cloudFlare func(email, apikey string, dns01Nameservers []string) (*cloudflare.DNSProvider, error) - route53 func(accessKey, secretKey, hostedZoneID, region string, ambient bool, dns01Nameservers []string) (*route53.DNSProvider, error) - azureDNS func(clientID, clientSecret, subscriptionID, tenentID, resourceGroupName, hostedZoneName string, dns01Nameservers []string) (*azuredns.DNSProvider, error) - acmeDNS func(host string, accountJson []byte, dns01Nameservers []string) (*acmedns.DNSProvider, error) - rfc2136 func(nameserver, tsigAlgorithm, tsigKeyName, tsigSecret string, dns01Nameservers []string) (*rfc2136.DNSProvider, error) + cloudDNS func(project string, serviceAccount []byte, dns01Nameservers []string, ambient bool) (*clouddns.DNSProvider, error) + cloudFlare func(email, apikey string, dns01Nameservers []string) (*cloudflare.DNSProvider, error) + route53 func(accessKey, secretKey, hostedZoneID, region string, ambient bool, dns01Nameservers []string) (*route53.DNSProvider, error) + azureDNS func(clientID, clientSecret, subscriptionID, tenentID, resourceGroupName, hostedZoneName string, dns01Nameservers []string) (*azuredns.DNSProvider, error) + acmeDNS func(host string, accountJson []byte, dns01Nameservers []string) (*acmedns.DNSProvider, error) + rfc2136 func(nameserver, tsigAlgorithm, tsigKeyName, tsigSecret string, dns01Nameservers []string) (*rfc2136.DNSProvider, error) + digitalOcean func(token string, dns01Nameservers []string) (*digitalocean.DNSProvider, error) } // Solver is a solver for the acme dns01 challenge. @@ -206,6 +208,18 @@ func (s *Solver) solverForChallenge(issuer v1alpha1.GenericIssuer, ch *v1alpha1. if err != nil { return nil, fmt.Errorf("error instantiating cloudflare challenge solver: %s", err) } + case providerConfig.DigitalOcean != nil: + apiTokenSecret, err := s.secretLister.Secrets(resourceNamespace).Get(providerConfig.DigitalOcean.Token.Name) + if err != nil { + return nil, fmt.Errorf("error getting digitalocean token: %s", err) + } + + apiToken := string(apiTokenSecret.Data[providerConfig.DigitalOcean.Token.Key]) + + impl, err = s.dnsProviderConstructors.digitalOcean(strings.TrimSpace(apiToken), s.DNS01Nameservers) + if err != nil { + return nil, fmt.Errorf("error instantiating digitalocean challenge solver: %s", err.Error()) + } case providerConfig.Route53 != nil: secretAccessKey := "" if providerConfig.Route53.SecretAccessKey.Name != "" { @@ -318,6 +332,7 @@ func NewSolver(ctx *controller.Context) *Solver { azuredns.NewDNSProviderCredentials, acmedns.NewDNSProviderHostBytes, rfc2136.NewDNSProviderCredentials, + digitalocean.NewDNSProviderCredentials, }, } } diff --git a/pkg/issuer/acme/dns/dns_test.go b/pkg/issuer/acme/dns/dns_test.go index 3f45b2389..567360547 100644 --- a/pkg/issuer/acme/dns/dns_test.go +++ b/pkg/issuer/acme/dns/dns_test.go @@ -264,6 +264,60 @@ func TestSolverFor(t *testing.T) { } } +func TestSolveForDigitalOcean(t *testing.T) { + f := &solverFixture{ + Builder: &test.Builder{ + KubeObjects: []runtime.Object{ + newSecret("digitalocean", "default", map[string][]byte{ + "token": []byte("FAKE-TOKEN"), + }), + }, + }, + Issuer: newIssuer("test", "default", []v1alpha1.ACMEIssuerDNS01Provider{ + { + Name: "fake-digitalocean", + DigitalOcean: &v1alpha1.ACMEIssuerDNS01ProviderDigitalOcean{ + Token: v1alpha1.SecretKeySelector{ + LocalObjectReference: v1alpha1.LocalObjectReference{ + Name: "digitalocean", + }, + Key: "token", + }, + }, + }, + }), + Challenge: v1alpha1.ACMEOrderChallenge{ + SolverConfig: v1alpha1.SolverConfig{ + DNS01: &v1alpha1.DNS01SolverConfig{ + Provider: "fake-digitalocean", + }, + }, + }, + dnsProviders: newFakeDNSProviders(), + } + + f.Setup(t) + defer f.Finish(t) + + s := f.Solver + _, err := s.solverForIssuerProvider(f.Issuer, f.Challenge.SolverConfig.DNS01.Provider) + if err != nil { + t.Fatalf("expected solverFor to not error, but got: %s", err) + } + + expectedDOCall := []fakeDNSProviderCall{ + { + name: "digitalocean", + args: []interface{}{"FAKE-TOKEN", util.RecursiveNameservers}, + }, + } + + if !reflect.DeepEqual(expectedDOCall, f.dnsProviders.calls) { + t.Fatalf("expected %+v == %+v", expectedDOCall, f.dnsProviders.calls) + } + +} + func TestRoute53TrimCreds(t *testing.T) { f := &solverFixture{ Builder: &test.Builder{ diff --git a/pkg/issuer/acme/dns/util_test.go b/pkg/issuer/acme/dns/util_test.go index 841dbd680..13c917682 100644 --- a/pkg/issuer/acme/dns/util_test.go +++ b/pkg/issuer/acme/dns/util_test.go @@ -18,6 +18,7 @@ package dns import ( "errors" + "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/digitalocean" "testing" "github.com/jetstack/cert-manager/test/util/generate" @@ -162,6 +163,10 @@ func newFakeDNSProviders() *fakeDNSProviders { f.call("rfc2136", nameserver, tsigAlgorithm, tsigKeyName, tsigSecret, util.RecursiveNameservers) return nil, nil }, + digitalOcean: func(token string, dns01Nameservers []string) (*digitalocean.DNSProvider, error) { + f.call("digitalocean", token, util.RecursiveNameservers) + return nil, nil + }, } return f }