Add ACME DigitalOcean DNS01 provider
Signed-off-by: Zadkiel Aharonian <hello@zadkiel.fr>
This commit is contained in:
parent
649d1ae9a0
commit
59e905cbcc
@ -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"
|
||||
|
||||
14
docs/reference/issuers/acme/dns01/digitalocean.rst
Normal file
14
docs/reference/issuers/acme/dns01/digitalocean.rst
Normal 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
|
||||
@ -67,4 +67,5 @@ and provider specific notes regarding their usage.
|
||||
azuredns
|
||||
cloudflare
|
||||
google
|
||||
route53
|
||||
route53
|
||||
digitalocean
|
||||
@ -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 {
|
||||
|
||||
@ -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"))
|
||||
|
||||
166
pkg/issuer/acme/dns/digitalocean/digitalocean.go
Normal file
166
pkg/issuer/acme/dns/digitalocean/digitalocean.go
Normal 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
|
||||
}
|
||||
95
pkg/issuer/acme/dns/digitalocean/digitalocean_test.go
Normal file
95
pkg/issuer/acme/dns/digitalocean/digitalocean_test.go
Normal 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) {
|
||||
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user