From cc89fe59aa018fd344446d615f25e64c18ba496e Mon Sep 17 00:00:00 2001 From: mwieczorek Date: Fri, 29 Dec 2017 12:15:50 +0100 Subject: [PATCH] Added Azure DNS support for DNS01 challange --- docs/examples/acme-cert.yaml | 6 + docs/examples/acme-issuer.yaml | 18 ++ pkg/apis/certmanager/v1alpha1/types.go | 14 ++ .../v1alpha1/zz_generated.deepcopy.go | 26 +++ pkg/issuer/acme/dns/azuredns/azuredns.go | 159 ++++++++++++++++++ pkg/issuer/acme/dns/azuredns/azuredns_test.go | 58 +++++++ pkg/issuer/acme/dns/dns.go | 20 +++ 7 files changed, 301 insertions(+) create mode 100644 pkg/issuer/acme/dns/azuredns/azuredns.go create mode 100644 pkg/issuer/acme/dns/azuredns/azuredns_test.go diff --git a/docs/examples/acme-cert.yaml b/docs/examples/acme-cert.yaml index 9176f953e..505aee87f 100644 --- a/docs/examples/acme-cert.yaml +++ b/docs/examples/acme-cert.yaml @@ -15,6 +15,7 @@ spec: - cm-dns-clouddns.k8s.group - cm-dns-cloudflare.k8s.group - cm-dns-route53.k8s.group + - cm-dns-azuredns.k8s.group acme: config: - http01: @@ -38,3 +39,8 @@ spec: provider: route53 domains: - cm-dns-route53.k8s.group + - dns-01: + provider: azuredns + domains: + - cm-dns-azuredns.k8s.group + diff --git a/docs/examples/acme-issuer.yaml b/docs/examples/acme-issuer.yaml index 0196d1832..66132fc18 100644 --- a/docs/examples/acme-issuer.yaml +++ b/docs/examples/acme-issuer.yaml @@ -46,3 +46,21 @@ spec: # This field is optional for overriding the Route53 hosted zone ID # It is required to use it if the cert-manager cannot disambiguate between two different hosted zones for the same zone name hostedZoneID: DIKER8JPL21PSA + - name: azuredns + azuredns: + # Service principal clientId (also called appId) + clientID: 8ff041f4-a14f-4753-80c2-101b35db5879 + # A secretKeyRef to a service principal ClientSecret (password) + # ref: https://docs.microsoft.com/en-us/azure/container-service/kubernetes/container-service-kubernetes-service-principal + clientSecretSecretRef: + name: azuredns-config + key: client-secret + # Azure subscription Id + subscriptionID: 0933cdcc-0cd0-4fb3-9f26-dac4fdc2154b + # Azure AD tenant Id + tenantID: 9581f7ad-8f4f-4f07-92df-12c821981ce8 + # ResourceGroup name where dns zone is provisioned + resourceGroupName: resource-group + # Name of the hosted zone, if ommited it will be computed from domain provided during certificate creation + # hosted zone name is always part of domain name from certificate request + hostedZoneName: k8s.group \ No newline at end of file diff --git a/pkg/apis/certmanager/v1alpha1/types.go b/pkg/apis/certmanager/v1alpha1/types.go index 0878c2cf0..7374bf980 100644 --- a/pkg/apis/certmanager/v1alpha1/types.go +++ b/pkg/apis/certmanager/v1alpha1/types.go @@ -114,6 +114,7 @@ type ACMEIssuerDNS01Provider struct { CloudDNS *ACMEIssuerDNS01ProviderCloudDNS `json:"clouddns,omitempty"` Cloudflare *ACMEIssuerDNS01ProviderCloudflare `json:"cloudflare,omitempty"` Route53 *ACMEIssuerDNS01ProviderRoute53 `json:"route53,omitempty"` + AzureDNS *ACMEIssuerDNS01ProviderAzureDNS `json:"azuredns,omitempty"` } // ACMEIssuerDNS01ProviderCloudDNS is a structure containing the DNS @@ -139,6 +140,19 @@ type ACMEIssuerDNS01ProviderRoute53 struct { Region string `json:"region"` } +// ACMEIssuerDNS01ProviderAzureDNS is a structure containing the +// configuration for Azure DNS +type ACMEIssuerDNS01ProviderAzureDNS struct { + ClientID string `json:"clientID"` + ClientSecret SecretKeySelector `json:"clientSecretSecretRef"` + SubscriptionID string `json:"subscriptionID"` + TenantID string `json:"tenantID"` + ResourceGroupName string `json:"resourceGroupName"` + + // + optional + HostedZoneName string `json:"hostedZoneName"` +} + // IssuerStatus contains status information about an Issuer type IssuerStatus struct { Conditions []IssuerCondition `json:"conditions"` diff --git a/pkg/apis/certmanager/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/certmanager/v1alpha1/zz_generated.deepcopy.go index e9b60db01..54a844bd2 100644 --- a/pkg/apis/certmanager/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/certmanager/v1alpha1/zz_generated.deepcopy.go @@ -231,6 +231,15 @@ func (in *ACMEIssuerDNS01Provider) DeepCopyInto(out *ACMEIssuerDNS01Provider) { **out = **in } } + if in.AzureDNS != nil { + in, out := &in.AzureDNS, &out.AzureDNS + if *in == nil { + *out = nil + } else { + *out = new(ACMEIssuerDNS01ProviderAzureDNS) + **out = **in + } + } return } @@ -244,6 +253,23 @@ func (in *ACMEIssuerDNS01Provider) DeepCopy() *ACMEIssuerDNS01Provider { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ACMEIssuerDNS01ProviderAzureDNS) DeepCopyInto(out *ACMEIssuerDNS01ProviderAzureDNS) { + *out = *in + out.ClientSecret = in.ClientSecret + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ACMEIssuerDNS01ProviderAzureDNS. +func (in *ACMEIssuerDNS01ProviderAzureDNS) DeepCopy() *ACMEIssuerDNS01ProviderAzureDNS { + if in == nil { + return nil + } + out := new(ACMEIssuerDNS01ProviderAzureDNS) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ACMEIssuerDNS01ProviderCloudDNS) DeepCopyInto(out *ACMEIssuerDNS01ProviderCloudDNS) { *out = *in diff --git a/pkg/issuer/acme/dns/azuredns/azuredns.go b/pkg/issuer/acme/dns/azuredns/azuredns.go new file mode 100644 index 000000000..d2dd9bddd --- /dev/null +++ b/pkg/issuer/acme/dns/azuredns/azuredns.go @@ -0,0 +1,159 @@ +// Package azuredns implements a DNS provider for solving the DNS-01 challenge +// using Azure DNS. +package azuredns + +import ( + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/arm/dns" + "github.com/Azure/go-autorest/autorest" + "github.com/Azure/go-autorest/autorest/adal" + "github.com/Azure/go-autorest/autorest/azure" + "github.com/Azure/go-autorest/autorest/to" + "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/util" +) + +// DNSProvider implements the util.ChallengeProvider interface +type DNSProvider struct { + recordClient dns.RecordSetsClient + zoneClient dns.ZonesClient + resourceGroupName string + zoneName string +} + +// NewDNSProvider returns a DNSProvider instance configured for the Azure +// DNS service. +// Credentials are automatically detected from environment variables +func NewDNSProvider() (*DNSProvider, error) { + + clientID := os.Getenv("AZURE_CLIENT_ID") + clientSecret := os.Getenv("AZURE_CLIENT_SECRET") + subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID") + tenantID := os.Getenv("AZURE_TENANT_ID") + resourceGroupName := ("AZURE_RESOURCE_GROUP") + zoneName := ("AZURE_ZONE_NAME") + + return NewDNSProviderCredentials(clientID, clientSecret, subscriptionID, tenantID, resourceGroupName, zoneName) +} + +// NewDNSProviderCredentials returns a DNSProvider instance configured for the Azure +// DNS service using static credentials from its parameters +func NewDNSProviderCredentials(clientID, clientSecret, subscriptionID, tenantID, resourceGroupName, zoneName string) (*DNSProvider, error) { + oauthConfig, err := adal.NewOAuthConfig(azure.PublicCloud.ActiveDirectoryEndpoint, tenantID) + if err != nil { + return nil, err + } + + spt, err := adal.NewServicePrincipalToken(*oauthConfig, clientID, clientSecret, azure.PublicCloud.ResourceManagerEndpoint) + if err != nil { + return nil, err + } + + rc := dns.NewRecordSetsClient(subscriptionID) + rc.Authorizer = autorest.NewBearerAuthorizer(spt) + + zc := dns.NewZonesClient(subscriptionID) + zc.Authorizer = autorest.NewBearerAuthorizer(spt) + + return &DNSProvider{ + recordClient: rc, + zoneClient: zc, + resourceGroupName: resourceGroupName, + zoneName: zoneName, + }, nil +} + +// Present creates a TXT record using the specified parameters +func (c *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := util.DNS01Record(domain, keyAuth) + + return c.createRecord(fqdn, value, ttl) +} + +// CleanUp removes the TXT record matching the specified parameters +func (c *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := util.DNS01Record(domain, keyAuth) + + z, err := c.getHostedZoneName(fqdn) + if err != nil { + log.Fatalf("Error getting hosted zone name for: %s, %v", fqdn, err) + return err + } + + _, err = c.recordClient.Delete( + c.resourceGroupName, + z, + c.trimFqdn(fqdn), + dns.TXT, "") + + if err != nil { + return err + } + return 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 +} + +func (c *DNSProvider) createRecord(fqdn, value string, ttl int) error { + rparams := &dns.RecordSet{ + RecordSetProperties: &dns.RecordSetProperties{ + TTL: to.Int64Ptr(int64(ttl)), + TxtRecords: &[]dns.TxtRecord{ + {Value: &[]string{value}}, + }, + }, + } + + z, err := c.getHostedZoneName(fqdn) + if err != nil { + log.Fatalf("Error getting hosted zone name for: %s, %v", fqdn, err) + return err + } + + _, err = c.recordClient.CreateOrUpdate( + c.resourceGroupName, + z, + c.trimFqdn(fqdn), + dns.TXT, + *rparams, "", "") + + if err != nil { + log.Fatalf("Error creating TXT: %s, %v", c.zoneName, err) + return err + } + return nil +} + +func (c *DNSProvider) getHostedZoneName(fqdn string) (string, error) { + if c.zoneName != "" { + return c.zoneName, nil + } + z, err := util.FindZoneByFqdn(fqdn, util.RecursiveNameservers) + if err != nil { + return "", err + } + + if len(z) == 0 { + return "", fmt.Errorf("Zone %s not found for domain %s", z, fqdn) + } + + _, err = c.zoneClient.Get(c.resourceGroupName, util.UnFqdn(z)) + + if err != nil { + return "", fmt.Errorf("Zone %s not found in AzureDNS for domain %s. Err: %v", z, fqdn, err) + } + + return util.UnFqdn(z), nil +} + +func (c *DNSProvider) trimFqdn(fqdn string) string { + return strings.TrimSuffix(strings.TrimSuffix(fqdn, "."), "."+c.zoneName) +} diff --git a/pkg/issuer/acme/dns/azuredns/azuredns_test.go b/pkg/issuer/acme/dns/azuredns/azuredns_test.go new file mode 100644 index 000000000..29f46ec4e --- /dev/null +++ b/pkg/issuer/acme/dns/azuredns/azuredns_test.go @@ -0,0 +1,58 @@ +package azuredns + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + azureLiveTest bool + azureClientID string + azureClientSecret string + azuresubscriptionID string + azureTenantID string + azureResourceGroupName string + azureHostedZoneName string + azureDomain string +) + +func init() { + azureClientID = os.Getenv("AZURE_CLIENT_ID") + azureClientSecret = os.Getenv("AZURE_CLIENT_SECRET") + azuresubscriptionID = os.Getenv("AZURE_SUBSCRIPTION_ID") + azureTenantID = os.Getenv("AZURE_TENANT_ID") + azureResourceGroupName = os.Getenv("AZURE_RESOURCE_GROUP") + azureHostedZoneName = os.Getenv("AZURE_ZONE_NAME") + azureDomain = os.Getenv("AZURE_DOMAIN") + if len(azureClientID) > 0 && len(azureClientSecret) > 0 && len(azureDomain) > 0 { + azureLiveTest = true + } +} + +func TestLiveAzureDnsPresent(t *testing.T) { + if !azureLiveTest { + t.Skip("skipping live test") + } + provider, err := NewDNSProviderCredentials(azureClientID, azureClientSecret, azuresubscriptionID, azureTenantID, azureResourceGroupName, azureHostedZoneName) + assert.NoError(t, err) + + err = provider.Present(azureDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestLiveAzureDnsCleanUp(t *testing.T) { + if !azureLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 5) + + provider, err := NewDNSProviderCredentials(azureClientID, azureClientSecret, azuresubscriptionID, azureTenantID, azureResourceGroupName, azureHostedZoneName) + assert.NoError(t, err) + + err = provider.CleanUp(azureDomain, "", "123d==") + assert.NoError(t, err) +} diff --git a/pkg/issuer/acme/dns/dns.go b/pkg/issuer/acme/dns/dns.go index fe1eccc40..87cb167c3 100644 --- a/pkg/issuer/acme/dns/dns.go +++ b/pkg/issuer/acme/dns/dns.go @@ -10,6 +10,7 @@ import ( corev1listers "k8s.io/client-go/listers/core/v1" "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + "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/route53" @@ -155,6 +156,25 @@ func (s *Solver) solverFor(crt *v1alpha1.Certificate, domain string) (solver, er if err != nil { return nil, fmt.Errorf("error instantiating route53 challenge solver: %s", err.Error()) } + case providerConfig.AzureDNS != nil: + clientSecret, err := s.secretLister.Secrets(s.resourceNamespace).Get(providerConfig.AzureDNS.ClientSecret.Name) + if err != nil { + return nil, fmt.Errorf("error getting azuredns client secret: %s", err.Error()) + } + + clientSecretBytes, ok := clientSecret.Data[providerConfig.AzureDNS.ClientSecret.Key] + if !ok { + return nil, fmt.Errorf("error getting azure dns client secret: key '%s' not found in secret", providerConfig.AzureDNS.ClientSecret.Key) + } + + impl, err = azuredns.NewDNSProviderCredentials( + providerConfig.AzureDNS.ClientID, + string(clientSecretBytes), + providerConfig.AzureDNS.SubscriptionID, + providerConfig.AzureDNS.TenantID, + providerConfig.AzureDNS.ResourceGroupName, + providerConfig.AzureDNS.HostedZoneName, + ) default: return nil, fmt.Errorf("no dns provider config specified for domain '%s'", domain) }