Add ACME DigitalOcean DNS01 provider

Signed-off-by: Zadkiel Aharonian <hello@zadkiel.fr>
This commit is contained in:
Zadkiel Aharonian 2018-10-19 17:10:40 +02:00
parent 649d1ae9a0
commit 59e905cbcc
No known key found for this signature in database
GPG Key ID: C93C54450571DC32
10 changed files with 384 additions and 14 deletions

View File

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

View File

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

View File

@ -67,4 +67,5 @@ and provider specific notes regarding their usage.
azuredns
cloudflare
google
route53
route53
digitalocean

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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