From f9c0fbd2986290953d4611c6c6827f05f98c04ab Mon Sep 17 00:00:00 2001 From: fuku Date: Sun, 1 Jul 2018 09:13:22 +0900 Subject: [PATCH] Add DNS Provider for NIFCLOUD DNS (#532) --- cli.go | 1 + providers/dns/dns_providers.go | 3 + providers/dns/nifcloud/client.go | 235 ++++++++++++++++++++++++ providers/dns/nifcloud/client_test.go | 167 +++++++++++++++++ providers/dns/nifcloud/nifcloud.go | 103 +++++++++++ providers/dns/nifcloud/nifcloud_test.go | 52 ++++++ 6 files changed, 561 insertions(+) create mode 100644 providers/dns/nifcloud/client.go create mode 100644 providers/dns/nifcloud/client_test.go create mode 100644 providers/dns/nifcloud/nifcloud.go create mode 100644 providers/dns/nifcloud/nifcloud_test.go diff --git a/cli.go b/cli.go index 0dcc3113..b007d8bb 100644 --- a/cli.go +++ b/cli.go @@ -217,6 +217,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tmanual:\tnone") fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_API_USER, NAMECHEAP_API_KEY") fmt.Fprintln(w, "\tnamedotcom:\tNAMECOM_USERNAME, NAMECOM_API_TOKEN") + fmt.Fprintln(w, "\tnifcloud:\tNIFCLOUD_ACCESS_KEY_ID, NIFCLOUD_SECRET_ACCESS_KEY") fmt.Fprintln(w, "\trackspace:\tRACKSPACE_USER, RACKSPACE_API_KEY") fmt.Fprintln(w, "\trfc2136:\tRFC2136_TSIG_KEY, RFC2136_TSIG_SECRET,\n\t\tRFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER") fmt.Fprintln(w, "\troute53:\tAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_HOSTED_ZONE_ID") diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 6e66b983..96881c19 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -27,6 +27,7 @@ import ( "github.com/xenolf/lego/providers/dns/linode" "github.com/xenolf/lego/providers/dns/namecheap" "github.com/xenolf/lego/providers/dns/namedotcom" + "github.com/xenolf/lego/providers/dns/nifcloud" "github.com/xenolf/lego/providers/dns/ns1" "github.com/xenolf/lego/providers/dns/otc" "github.com/xenolf/lego/providers/dns/ovh" @@ -88,6 +89,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error) return namecheap.NewDNSProvider() case "namedotcom": return namedotcom.NewDNSProvider() + case "nifcloud": + return nifcloud.NewDNSProvider() case "rackspace": return rackspace.NewDNSProvider() case "route53": diff --git a/providers/dns/nifcloud/client.go b/providers/dns/nifcloud/client.go new file mode 100644 index 00000000..86b6fa65 --- /dev/null +++ b/providers/dns/nifcloud/client.go @@ -0,0 +1,235 @@ +// Package nifcloud implements a DNS provider for solving the DNS-01 challenge +// using NIFCLOUD DNS. +package nifcloud + +import ( + "bytes" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/xml" + "errors" + "fmt" + "net/http" + "time" +) + +const ( + defaultEndpoint = "https://dns.api.cloud.nifty.com" + apiVersion = "2012-12-12N2013-12-16" + xmlNs = "https://route53.amazonaws.com/doc/2012-12-12/" +) + +// ChangeResourceRecordSetsRequest is a complex type that contains change information for the resource record set. +type ChangeResourceRecordSetsRequest struct { + XMLNs string `xml:"xmlns,attr"` + ChangeBatch ChangeBatch `xml:"ChangeBatch"` +} + +// ChangeResourceRecordSetsResponse is a complex type containing the response for the request. +type ChangeResourceRecordSetsResponse struct { + ChangeInfo ChangeInfo `xml:"ChangeInfo"` +} + +// GetChangeResponse is a complex type that contains the ChangeInfo element. +type GetChangeResponse struct { + ChangeInfo ChangeInfo `xml:"ChangeInfo"` +} + +// ErrorResponse is the information for any errors. +type ErrorResponse struct { + Error struct { + Type string `xml:"Type"` + Message string `xml:"Message"` + Code string `xml:"Code"` + } `xml:"Error"` + RequestID string `xml:"RequestId"` +} + +// ChangeBatch is the information for a change request. +type ChangeBatch struct { + Changes Changes `xml:"Changes"` + Comment string `xml:"Comment"` +} + +// Changes is array of Change. +type Changes struct { + Change []Change `xml:"Change"` +} + +// Change is the information for each resource record set that you want to change. +type Change struct { + Action string `xml:"Action"` + ResourceRecordSet ResourceRecordSet `xml:"ResourceRecordSet"` +} + +// ResourceRecordSet is the information about the resource record set to create or delete. +type ResourceRecordSet struct { + Name string `xml:"Name"` + Type string `xml:"Type"` + TTL int `xml:"TTL"` + ResourceRecords ResourceRecords `xml:"ResourceRecords"` +} + +// ResourceRecords is array of ResourceRecord. +type ResourceRecords struct { + ResourceRecord []ResourceRecord `xml:"ResourceRecord"` +} + +// ResourceRecord is the information specific to the resource record. +type ResourceRecord struct { + Value string `xml:"Value"` +} + +// ChangeInfo is A complex type that describes change information about changes made to your hosted zone. +type ChangeInfo struct { + ID string `xml:"Id"` + Status string `xml:"Status"` + SubmittedAt string `xml:"SubmittedAt"` +} + +func newClient(httpClient *http.Client, accessKey string, secretKey string, endpoint string) *Client { + client := http.DefaultClient + if httpClient != nil { + client = httpClient + } + + return &Client{ + accessKey: accessKey, + secretKey: secretKey, + endpoint: endpoint, + client: client, + } +} + +// Client client of NIFCLOUD DNS +type Client struct { + accessKey string + secretKey string + endpoint string + client *http.Client +} + +// ChangeResourceRecordSets Call ChangeResourceRecordSets API and return response. +func (c *Client) ChangeResourceRecordSets(hostedZoneID string, input ChangeResourceRecordSetsRequest) (*ChangeResourceRecordSetsResponse, error) { + requestURL := fmt.Sprintf("%s/%s/hostedzone/%s/rrset", c.endpoint, apiVersion, hostedZoneID) + + body := &bytes.Buffer{} + body.Write([]byte(xml.Header)) + err := xml.NewEncoder(body).Encode(input) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, requestURL, body) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "text/xml; charset=utf-8") + + err = c.sign(req) + if err != nil { + return nil, fmt.Errorf("an error occurred during the creation of the signature: %v", err) + } + + res, err := c.client.Do(req) + if err != nil { + return nil, err + } + if res.Body == nil { + return nil, errors.New("the response body is nil") + } + + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + errResp := &ErrorResponse{} + err = xml.NewDecoder(res.Body).Decode(errResp) + if err != nil { + return nil, fmt.Errorf("an error occurred while unmarshaling the error body to XML: %v", err) + } + + return nil, fmt.Errorf("an error occurred: %s", errResp.Error.Message) + } + + output := &ChangeResourceRecordSetsResponse{} + err = xml.NewDecoder(res.Body).Decode(output) + if err != nil { + return nil, fmt.Errorf("an error occurred while unmarshaling the response body to XML: %v", err) + } + + return output, err +} + +// GetChange Call GetChange API and return response. +func (c *Client) GetChange(statusID string) (*GetChangeResponse, error) { + requestURL := fmt.Sprintf("%s/%s/change/%s", c.endpoint, apiVersion, statusID) + + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + return nil, err + } + + err = c.sign(req) + if err != nil { + return nil, fmt.Errorf("an error occurred during the creation of the signature: %v", err) + } + + res, err := c.client.Do(req) + if err != nil { + return nil, err + } + if res.Body == nil { + return nil, errors.New("the response body is nil") + } + + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + errResp := &ErrorResponse{} + err = xml.NewDecoder(res.Body).Decode(errResp) + if err != nil { + return nil, fmt.Errorf("an error occurred while unmarshaling the error body to XML: %v", err) + } + + return nil, fmt.Errorf("an error occurred: %s", errResp.Error.Message) + } + + output := &GetChangeResponse{} + err = xml.NewDecoder(res.Body).Decode(output) + if err != nil { + return nil, fmt.Errorf("an error occurred while unmarshaling the response body to XML: %v", err) + } + + return output, nil +} + +func (c *Client) sign(req *http.Request) error { + if req.Header.Get("Date") == "" { + location, err := time.LoadLocation("GMT") + if err != nil { + return err + } + + req.Header.Set("Date", time.Now().In(location).Format(time.RFC1123)) + } + + if req.URL.Path == "" { + req.URL.Path += "/" + } + + mac := hmac.New(sha1.New, []byte(c.secretKey)) + _, err := mac.Write([]byte(req.Header.Get("Date"))) + if err != nil { + return err + } + + hashed := mac.Sum(nil) + signature := base64.StdEncoding.EncodeToString(hashed) + + auth := fmt.Sprintf("NIFTY3-HTTPS NiftyAccessKeyId=%s,Algorithm=HmacSHA1,Signature=%s", c.accessKey, signature) + req.Header.Set("X-Nifty-Authorization", auth) + + return nil +} diff --git a/providers/dns/nifcloud/client_test.go b/providers/dns/nifcloud/client_test.go new file mode 100644 index 00000000..cc8e69f0 --- /dev/null +++ b/providers/dns/nifcloud/client_test.go @@ -0,0 +1,167 @@ +package nifcloud + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runTestServer(responseBody string, statusCode int) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(statusCode) + fmt.Fprintln(w, responseBody) + })) + return server +} + +func TestChangeResourceRecordSets(t *testing.T) { + responseBody := ` + + + xxxxx + INSYNC + 2015-08-05T00:00:00.000Z + + +` + server := runTestServer(responseBody, http.StatusOK) + defer server.Close() + + client := newClient(nil, "", "", server.URL) + + res, err := client.ChangeResourceRecordSets("example.com", ChangeResourceRecordSetsRequest{}) + require.NoError(t, err) + assert.Equal(t, "xxxxx", res.ChangeInfo.ID) + assert.Equal(t, "INSYNC", res.ChangeInfo.Status) + assert.Equal(t, "2015-08-05T00:00:00.000Z", res.ChangeInfo.SubmittedAt) +} + +func TestChangeResourceRecordSetsErrors(t *testing.T) { + testCases := []struct { + desc string + responseBody string + statusCode int + expected string + }{ + { + desc: "API error", + responseBody: ` + + + Sender + AuthFailed + The request signature we calculated does not match the signature you provided. + + +`, + statusCode: http.StatusUnauthorized, + expected: "an error occurred: The request signature we calculated does not match the signature you provided.", + }, + { + desc: "response body error", + responseBody: "foo", + statusCode: http.StatusOK, + expected: "an error occurred while unmarshaling the response body to XML: EOF", + }, + { + desc: "error message error", + responseBody: "foo", + statusCode: http.StatusInternalServerError, + expected: "an error occurred while unmarshaling the error body to XML: EOF", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + + server := runTestServer(test.responseBody, test.statusCode) + defer server.Close() + + client := newClient(nil, "", "", server.URL) + + res, err := client.ChangeResourceRecordSets("example.com", ChangeResourceRecordSetsRequest{}) + assert.Nil(t, res) + assert.EqualError(t, err, test.expected) + }) + } +} + +func TestGetChange(t *testing.T) { + responseBody := ` + + + xxxxx + INSYNC + 2015-08-05T00:00:00.000Z + + +` + + server := runTestServer(responseBody, http.StatusOK) + defer server.Close() + + client := newClient(nil, "", "", server.URL) + + res, err := client.GetChange("12345") + require.NoError(t, err) + assert.Equal(t, "xxxxx", res.ChangeInfo.ID) + assert.Equal(t, "INSYNC", res.ChangeInfo.Status) + assert.Equal(t, "2015-08-05T00:00:00.000Z", res.ChangeInfo.SubmittedAt) +} + +func TestGetChangeErrors(t *testing.T) { + testCases := []struct { + desc string + responseBody string + statusCode int + expected string + }{ + { + desc: "API error", + responseBody: ` + + + Sender + AuthFailed + The request signature we calculated does not match the signature you provided. + + +`, + statusCode: http.StatusUnauthorized, + expected: "an error occurred: The request signature we calculated does not match the signature you provided.", + }, + { + desc: "response body error", + responseBody: "foo", + statusCode: http.StatusOK, + expected: "an error occurred while unmarshaling the response body to XML: EOF", + }, + { + desc: "error message error", + responseBody: "foo", + statusCode: http.StatusInternalServerError, + expected: "an error occurred while unmarshaling the error body to XML: EOF", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + + server := runTestServer(test.responseBody, test.statusCode) + defer server.Close() + + client := newClient(nil, "", "", server.URL) + + res, err := client.GetChange("12345") + assert.Nil(t, res) + assert.EqualError(t, err, test.expected) + }) + } + +} diff --git a/providers/dns/nifcloud/nifcloud.go b/providers/dns/nifcloud/nifcloud.go new file mode 100644 index 00000000..5a7fa6b8 --- /dev/null +++ b/providers/dns/nifcloud/nifcloud.go @@ -0,0 +1,103 @@ +// Package nifcloud implements a DNS provider for solving the DNS-01 challenge +// using NIFCLOUD DNS. +package nifcloud + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/platform/config/env" +) + +// DNSProvider implements the acme.ChallengeProvider interface +type DNSProvider struct { + client *Client +} + +// NewDNSProvider returns a DNSProvider instance configured for the NIFCLOUD DNS service. +// Credentials must be passed in the environment variables: NIFCLOUD_ACCESS_KEY_ID and NIFCLOUD_SECRET_ACCESS_KEY. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get("NIFCLOUD_ACCESS_KEY_ID", "NIFCLOUD_SECRET_ACCESS_KEY") + if err != nil { + return nil, fmt.Errorf("NIFCLOUD: %v", err) + } + + endpoint := os.Getenv("NIFCLOUD_DNS_ENDPOINT") + if endpoint == "" { + endpoint = defaultEndpoint + } + + httpClient := &http.Client{Timeout: 30 * time.Second} + + return NewDNSProviderCredentials(httpClient, endpoint, values["NIFCLOUD_ACCESS_KEY_ID"], values["NIFCLOUD_SECRET_ACCESS_KEY"]) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for NIFCLOUD. +func NewDNSProviderCredentials(httpClient *http.Client, endpoint, accessKey, secretKey string) (*DNSProvider, error) { + client := newClient(httpClient, accessKey, secretKey, endpoint) + + return &DNSProvider{ + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + return d.changeRecord("CREATE", fqdn, value, domain, ttl) +} + +// CleanUp removes the TXT record matching the specified parameters +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + return d.changeRecord("DELETE", fqdn, value, domain, ttl) +} + +func (d *DNSProvider) changeRecord(action, fqdn, value, domain string, ttl int) error { + name := acme.UnFqdn(fqdn) + + reqParams := ChangeResourceRecordSetsRequest{ + XMLNs: xmlNs, + ChangeBatch: ChangeBatch{ + Comment: "Managed by Lego", + Changes: Changes{ + Change: []Change{ + { + Action: action, + ResourceRecordSet: ResourceRecordSet{ + Name: name, + Type: "TXT", + TTL: ttl, + ResourceRecords: ResourceRecords{ + ResourceRecord: []ResourceRecord{ + { + Value: value, + }, + }, + }, + }, + }, + }, + }, + }, + } + + resp, err := d.client.ChangeResourceRecordSets(domain, reqParams) + if err != nil { + return fmt.Errorf("failed to change NIFCLOUD record set: %v", err) + } + + statusID := resp.ChangeInfo.ID + + return acme.WaitFor(120*time.Second, 4*time.Second, func() (bool, error) { + resp, err := d.client.GetChange(statusID) + if err != nil { + return false, fmt.Errorf("failed to query NIFCLOUD DNS change status: %v", err) + } + return resp.ChangeInfo.Status == "INSYNC", nil + }) +} diff --git a/providers/dns/nifcloud/nifcloud_test.go b/providers/dns/nifcloud/nifcloud_test.go new file mode 100644 index 00000000..363e635c --- /dev/null +++ b/providers/dns/nifcloud/nifcloud_test.go @@ -0,0 +1,52 @@ +package nifcloud + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + nifcloudLiveTest bool + nifcloudAccessKey string + nifcloudSecretKey string + nifcloudDomain string +) + +func init() { + nifcloudAccessKey = os.Getenv("NIFCLOUD_ACCESS_KEY_ID") + nifcloudSecretKey = os.Getenv("NIFCLOUD_SECRET_ACCESS_KEY") + nifcloudDomain = os.Getenv("NIFCLOUD_DOMAIN") + + if len(nifcloudAccessKey) > 0 && len(nifcloudSecretKey) > 0 && len(nifcloudDomain) > 0 { + nifcloudLiveTest = true + } +} + +func TestLivenifcloudPresent(t *testing.T) { + if !nifcloudLiveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.Present(nifcloudDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestLivenifcloudCleanUp(t *testing.T) { + if !nifcloudLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 1) + + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.CleanUp(nifcloudDomain, "", "123d==") + assert.NoError(t, err) +}