From 06a8e7c475c03ef8d4773284ac63357d2810601b Mon Sep 17 00:00:00 2001 From: Giuem Date: Sat, 3 Feb 2018 03:41:35 +0800 Subject: [PATCH] Add DNS Provider for CloudXNS (#415) * Add DNS Provider for CloudXNS * Fix package path error * Fix typo CloudFlare -> CloudXNS * Fix typo makeReauest -> makeRequest * Change http.Client to acme.HTTPClient --- cli.go | 1 + providers/dns/cloudxns/cloudxns.go | 214 ++++++++++++++++++++++++ providers/dns/cloudxns/cloudxns_test.go | 80 +++++++++ providers/dns/dns_providers.go | 3 + 4 files changed, 298 insertions(+) create mode 100644 providers/dns/cloudxns/cloudxns.go create mode 100644 providers/dns/cloudxns/cloudxns_test.go diff --git a/cli.go b/cli.go index c09b246d..5f0dc57d 100644 --- a/cli.go +++ b/cli.go @@ -203,6 +203,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w) fmt.Fprintln(w, "\tazure:\tAZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, AZURE_RESOURCE_GROUP") fmt.Fprintln(w, "\tauroradns:\tAURORA_USER_ID, AURORA_KEY, AURORA_ENDPOINT") + fmt.Fprintln(w, "\tcloudxns:\tCLOUDXNS_API_KEY, CLOUDXNS_SECRET_KEY") fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY") fmt.Fprintln(w, "\tdigitalocean:\tDO_AUTH_TOKEN") fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_OAUTH_TOKEN") diff --git a/providers/dns/cloudxns/cloudxns.go b/providers/dns/cloudxns/cloudxns.go new file mode 100644 index 00000000..59697417 --- /dev/null +++ b/providers/dns/cloudxns/cloudxns.go @@ -0,0 +1,214 @@ +// Package cloudxns implements a DNS provider for solving the DNS-01 challenge +// using cloudxns DNS. +package cloudxns + +import ( + "bytes" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "time" + + "github.com/xenolf/lego/acme" +) + +const cloudXNSBaseURL = "https://www.cloudxns.net/api2/" + +// DNSProvider is an implementation of the acme.ChallengeProvider interface +type DNSProvider struct { + apiKey string + secretKey string +} + +// NewDNSProvider returns a DNSProvider instance configured for cloudxns. +// Credentials must be passed in the environment variables: CLOUDXNS_API_KEY +// and CLOUDXNS_SECRET_KEY. +func NewDNSProvider() (*DNSProvider, error) { + apiKey := os.Getenv("CLOUDXNS_API_KEY") + secretKey := os.Getenv("CLOUDXNS_SECRET_KEY") + return NewDNSProviderCredentials(apiKey, secretKey) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for cloudxns. +func NewDNSProviderCredentials(apiKey, secretKey string) (*DNSProvider, error) { + if apiKey == "" || secretKey == "" { + return nil, fmt.Errorf("CloudXNS credentials missing") + } + + return &DNSProvider{ + apiKey: apiKey, + secretKey: secretKey, + }, 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) + zoneID, err := c.getHostedZoneID(fqdn) + if err != nil { + return err + } + + return c.addTxtRecord(zoneID, fqdn, value, ttl) +} + +// CleanUp removes the TXT record matching the specified parameters. +func (c *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := acme.DNS01Record(domain, keyAuth) + zoneID, err := c.getHostedZoneID(fqdn) + if err != nil { + return err + } + + recordID, err := c.findTxtRecord(zoneID, fqdn) + if err != nil { + return err + } + + return c.delTxtRecord(recordID, zoneID) +} + +func (c *DNSProvider) getHostedZoneID(fqdn string) (string, error) { + type Data struct { + ID string `json:"id"` + Domain string `json:"domain"` + } + + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return "", err + } + + result, err := c.makeRequest("GET", "domain", nil) + if err != nil { + return "", err + } + + var domains []Data + err = json.Unmarshal(result, &domains) + if err != nil { + return "", err + } + + for _, data := range domains { + if data.Domain == authZone { + return data.ID, nil + } + } + + return "", fmt.Errorf("Zone %s not found in cloudxns for domain %s", authZone, fqdn) +} + +func (c *DNSProvider) findTxtRecord(zoneID, fqdn string) (string, error) { + result, err := c.makeRequest("GET", fmt.Sprintf("record/%s?host_id=0&offset=0&row_num=2000", zoneID), nil) + if err != nil { + return "", err + } + + var records []cloudXNSRecord + err = json.Unmarshal(result, &records) + if err != nil { + return "", err + } + + for _, record := range records { + if record.Host == acme.UnFqdn(fqdn) && record.Type == "TXT" { + return record.RecordID, nil + } + } + + return "", fmt.Errorf("No existing record found for %s", fqdn) +} + +func (c *DNSProvider) addTxtRecord(zoneID, fqdn, value string, ttl int) error { + id, err := strconv.Atoi(zoneID) + if err != nil { + return err + } + + payload := cloudXNSRecord{ + ID: id, + Host: acme.UnFqdn(fqdn), + Value: value, + Type: "TXT", + LineID: 1, + TTL: ttl, + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + _, err = c.makeRequest("POST", "record", body) + if err != nil { + return err + } + + return nil +} + +func (c *DNSProvider) delTxtRecord(recordID, zoneID string) error { + _, err := c.makeRequest("DELETE", fmt.Sprintf("record/%s/%s", recordID, zoneID), nil) + return err +} + +func (c *DNSProvider) hmac(url, date, body string) string { + sum := md5.Sum([]byte(c.apiKey + url + body + date + c.secretKey)) + return hex.EncodeToString(sum[:]) +} + +func (c *DNSProvider) makeRequest(method, uri string, body []byte) (json.RawMessage, error) { + type APIResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` + } + + url := cloudXNSBaseURL + uri + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + requestDate := time.Now().Format(time.RFC1123Z) + + req.Header.Set("API-KEY", c.apiKey) + req.Header.Set("API-REQUEST-DATE", requestDate) + req.Header.Set("API-HMAC", c.hmac(url, requestDate, string(body))) + req.Header.Set("API-FORMAT", "json") + + resp, err := acme.HTTPClient.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + var r APIResponse + err = json.NewDecoder(resp.Body).Decode(&r) + if err != nil { + return nil, err + } + + if r.Code != 1 { + return nil, fmt.Errorf("CloudXNS API Error: %s", r.Message) + } + return r.Data, nil +} + +type cloudXNSRecord struct { + ID int `json:"domain_id,omitempty"` + RecordID string `json:"record_id,omitempty"` + + Host string `json:"host"` + Value string `json:"value"` + Type string `json:"type"` + LineID int `json:"line_id,string"` + TTL int `json:"ttl,string"` +} diff --git a/providers/dns/cloudxns/cloudxns_test.go b/providers/dns/cloudxns/cloudxns_test.go new file mode 100644 index 00000000..8f26ba82 --- /dev/null +++ b/providers/dns/cloudxns/cloudxns_test.go @@ -0,0 +1,80 @@ +package cloudxns + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + cxLiveTest bool + cxAPIKey string + cxSecretKey string + cxDomain string +) + +func init() { + cxAPIKey = os.Getenv("CLOUDXNS_API_KEY") + cxSecretKey = os.Getenv("CLOUDXNS_SECRET_KEY") + cxDomain = os.Getenv("CLOUDXNS_DOMAIN") + if len(cxAPIKey) > 0 && len(cxSecretKey) > 0 && len(cxDomain) > 0 { + cxLiveTest = true + } +} + +func restoreCloudXNSEnv() { + os.Setenv("CLOUDXNS_API_KEY", cxAPIKey) + os.Setenv("CLOUDXNS_SECRET_KEY", cxSecretKey) +} + +func TestNewDNSProviderValid(t *testing.T) { + os.Setenv("CLOUDXNS_API_KEY", "") + os.Setenv("CLOUDXNS_SECRET_KEY", "") + _, err := NewDNSProviderCredentials("123", "123") + assert.NoError(t, err) + restoreCloudXNSEnv() +} + +func TestNewDNSProviderValidEnv(t *testing.T) { + os.Setenv("CLOUDXNS_API_KEY", "123") + os.Setenv("CLOUDXNS_SECRET_KEY", "123") + _, err := NewDNSProvider() + assert.NoError(t, err) + restoreCloudXNSEnv() +} + +func TestNewDNSProviderMissingCredErr(t *testing.T) { + os.Setenv("CLOUDXNS_API_KEY", "") + os.Setenv("CLOUDXNS_SECRET_KEY", "") + _, err := NewDNSProvider() + assert.EqualError(t, err, "CloudXNS credentials missing") + restoreCloudXNSEnv() +} + +func TestCloudXNSPresent(t *testing.T) { + if !cxLiveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProviderCredentials(cxAPIKey, cxSecretKey) + assert.NoError(t, err) + + err = provider.Present(cxDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestCloudXNSCleanUp(t *testing.T) { + if !cxLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 2) + + provider, err := NewDNSProviderCredentials(cxAPIKey, cxSecretKey) + assert.NoError(t, err) + + err = provider.CleanUp(cxDomain, "", "123d==") + assert.NoError(t, err) +} diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 50d40f10..06235309 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -8,6 +8,7 @@ import ( "github.com/xenolf/lego/providers/dns/auroradns" "github.com/xenolf/lego/providers/dns/azure" "github.com/xenolf/lego/providers/dns/cloudflare" + "github.com/xenolf/lego/providers/dns/cloudxns" "github.com/xenolf/lego/providers/dns/digitalocean" "github.com/xenolf/lego/providers/dns/dnsimple" "github.com/xenolf/lego/providers/dns/dnsmadeeasy" @@ -40,6 +41,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error) provider, err = auroradns.NewDNSProvider() case "cloudflare": provider, err = cloudflare.NewDNSProvider() + case "cloudxns": + provider, err = cloudxns.NewDNSProvider() case "digitalocean": provider, err = digitalocean.NewDNSProvider() case "dnsimple":