From 4da4506839542e67eab9993c930b0edf04a7f7ec Mon Sep 17 00:00:00 2001 From: Peter Waldschmidt Date: Fri, 18 Mar 2016 11:22:33 -0400 Subject: [PATCH] Add DNS challenge provider for Google Cloud DNS Use GCE_PROJECT to designate your GCE project. Authentication is automatically picked up from gcloud credentials if running locally and from GCE metadata if run within Google Cloud. Requires at least permission scope "https://www.googleapis.com/auth/ndev.clouddns.readwrite" --- cli.go | 1 + cli_handlers.go | 3 + .../googlecloud/dns_challenge_googlecloud.go | 151 ++++++++++++++++++ .../dns_challenge_googlecloud_test.go | 85 ++++++++++ 4 files changed, 240 insertions(+) create mode 100644 providers/dns/googlecloud/dns_challenge_googlecloud.go create mode 100644 providers/dns/googlecloud/dns_challenge_googlecloud_test.go diff --git a/cli.go b/cli.go index ecf1c500..77fce0c6 100644 --- a/cli.go +++ b/cli.go @@ -163,6 +163,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tdigitalocean:\tDO_AUTH_TOKEN") fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_API_KEY") fmt.Fprintln(w, "\tgandi:\tGANDI_API_KEY") + fmt.Fprintln(w, "\tgcloud:\tGCE_PROJECT") fmt.Fprintln(w, "\tmanual:\tnone") fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_API_USER, NAMECHEAP_API_KEY") fmt.Fprintln(w, "\trfc2136:\tRFC2136_TSIG_KEY, RFC2136_TSIG_SECRET,\n\t\tRFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER") diff --git a/cli_handlers.go b/cli_handlers.go index eee7508d..a178816f 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -15,6 +15,7 @@ import ( "github.com/xenolf/lego/providers/dns/digitalocean" "github.com/xenolf/lego/providers/dns/dnsimple" "github.com/xenolf/lego/providers/dns/gandi" + "github.com/xenolf/lego/providers/dns/googlecloud" "github.com/xenolf/lego/providers/dns/namecheap" "github.com/xenolf/lego/providers/dns/rfc2136" "github.com/xenolf/lego/providers/dns/route53" @@ -97,6 +98,8 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { case "gandi": apiKey := os.Getenv("GANDI_API_KEY") provider, err = gandi.NewDNSProvider(apiKey) + case "gcloud": + provider, err = googleclouddns.NewDNSProvider("") case "namecheap": provider, err = namecheap.NewDNSProvider("", "") case "route53": diff --git a/providers/dns/googlecloud/dns_challenge_googlecloud.go b/providers/dns/googlecloud/dns_challenge_googlecloud.go new file mode 100644 index 00000000..fb8d63d2 --- /dev/null +++ b/providers/dns/googlecloud/dns_challenge_googlecloud.go @@ -0,0 +1,151 @@ +package googleclouddns + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/xenolf/lego/acme" + + "golang.org/x/net/context" + "golang.org/x/oauth2/google" + + "google.golang.org/api/dns/v1" +) + +// DNSProvider is an implementation of the DNSProvider interface. +type DNSProvider struct { + project string + client *dns.Service +} + +// NewDNSProvider returns a DNSProvider instance with a configured gcloud client. +// Authentication is done using the local account credentials managed by the gcloud utility. +func NewDNSProvider(project string) (*DNSProvider, error) { + if project == "" { + project = gcloudEnvAuth() + } + if project == "" { + return nil, fmt.Errorf("Google Cloud project name missing") + } + + client, err := google.DefaultClient(context.Background(), dns.NdevClouddnsReadwriteScope) + if err != nil { + return nil, fmt.Errorf("Unable to get Google Cloud client: %v", err) + } + svc, err := dns.New(client) + if err != nil { + return nil, fmt.Errorf("Unable to create Google Cloud DNS service: %v", err) + } + return &DNSProvider{ + project: project, + client: svc, + }, nil +} + +// Present creates a TXT record to fulfil the dns-01 challenge. +func (c *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + + zone, err := c.getHostedZone(domain) + if err != nil { + return err + } + + rec := &dns.ResourceRecordSet{ + Name: fqdn, + Rrdatas: []string{value}, + Ttl: int64(ttl), + Type: "TXT", + } + change := &dns.Change{ + Additions: []*dns.ResourceRecordSet{rec}, + } + + chg, err := c.client.Changes.Create(c.project, zone, change).Do() + if err != nil { + return err + } + + // wait for change to be acknowledged + for chg.Status == "pending" { + time.Sleep(time.Second) + + chg, err = c.client.Changes.Get(c.project, zone, chg.Id).Do() + 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, _, _ := acme.DNS01Record(domain, keyAuth) + + zone, err := c.getHostedZone(domain) + if err != nil { + return err + } + + records, err := c.findTxtRecords(zone, fqdn) + if err != nil { + return err + } + + for _, rec := range records { + change := &dns.Change{ + Deletions: []*dns.ResourceRecordSet{rec}, + } + _, err = c.client.Changes.Create(c.project, zone, change).Do() + if err != nil { + return err + } + } + return nil +} + +func (c *DNSProvider) Timeout() (timeout, interval time.Duration) { + return 180 * time.Second, 5 * time.Second +} + +// getHostedZone returns the managed-zone +func (c *DNSProvider) getHostedZone(domain string) (string, error) { + + zones, err := c.client.ManagedZones.List(c.project).Do() + if err != nil { + return "", fmt.Errorf("GoogleCloud API call failed: %v", err) + } + + for _, z := range zones.ManagedZones { + if strings.HasSuffix(domain+".", z.DnsName) { + return z.Name, nil + } + } + + return "", fmt.Errorf("No matching GoogleCloud domain found for domain %s", domain) + +} + +func (c *DNSProvider) findTxtRecords(zone, fqdn string) ([]*dns.ResourceRecordSet, error) { + + recs, err := c.client.ResourceRecordSets.List(c.project, zone).Do() + if err != nil { + return nil, err + } + + found := []*dns.ResourceRecordSet{} + for _, r := range recs.Rrsets { + if r.Type == "TXT" && r.Name == fqdn { + found = append(found, r) + } + } + + return found, nil +} + +func gcloudEnvAuth() (gcloud string) { + return os.Getenv("GCE_PROJECT") +} diff --git a/providers/dns/googlecloud/dns_challenge_googlecloud_test.go b/providers/dns/googlecloud/dns_challenge_googlecloud_test.go new file mode 100644 index 00000000..4d03468e --- /dev/null +++ b/providers/dns/googlecloud/dns_challenge_googlecloud_test.go @@ -0,0 +1,85 @@ +package googleclouddns + +import ( + "os" + "testing" + "time" + + "golang.org/x/net/context" + "golang.org/x/oauth2/google" + "google.golang.org/api/dns/v1" + + "github.com/stretchr/testify/assert" +) + +var ( + gcloudLiveTest bool + gcloudProject string + gcloudDomain string +) + +func init() { + gcloudProject = os.Getenv("GCE_PROJECT") + gcloudDomain = os.Getenv("GCE_DOMAIN") + _, err := google.DefaultClient(context.Background(), dns.NdevClouddnsReadwriteScope) + if err == nil && len(gcloudProject) > 0 && len(gcloudDomain) > 0 { + gcloudLiveTest = true + } +} + +func restoreGCloudEnv() { + os.Setenv("GCE_PROJECT", gcloudProject) +} + +func TestNewDNSProviderValid(t *testing.T) { + if !gcloudLiveTest { + t.Skip("skipping live test (requires credentials)") + } + os.Setenv("GCE_PROJECT", "") + _, err := NewDNSProvider("my-project") + assert.NoError(t, err) + restoreGCloudEnv() +} + +func TestNewDNSProviderValidEnv(t *testing.T) { + if !gcloudLiveTest { + t.Skip("skipping live test (requires credentials)") + } + os.Setenv("GCE_PROJECT", "my-project") + _, err := NewDNSProvider("") + assert.NoError(t, err) + restoreGCloudEnv() +} + +func TestNewDNSProviderMissingCredErr(t *testing.T) { + os.Setenv("GCE_PROJECT", "") + _, err := NewDNSProvider("") + assert.EqualError(t, err, "Google Cloud project name missing") + restoreGCloudEnv() +} + +func TestLiveGoogleCloudPresent(t *testing.T) { + if !gcloudLiveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProvider(gcloudProject) + assert.NoError(t, err) + + err = provider.Present(gcloudDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestLiveGoogleCloudCleanUp(t *testing.T) { + if !gcloudLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 1) + + provider, err := NewDNSProvider(gcloudProject) + assert.NoError(t, err) + + err = provider.CleanUp(gcloudDomain, "", "123d==") + assert.NoError(t, err) +}