diff --git a/providers/dns/cloudxns/client.go b/providers/dns/cloudxns/client.go new file mode 100644 index 00000000..4cf1dd20 --- /dev/null +++ b/providers/dns/cloudxns/client.go @@ -0,0 +1,208 @@ +package cloudxns + +import ( + "bytes" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strconv" + "strings" + "time" + + "github.com/xenolf/lego/acme" +) + +const defaultBaseURL = "https://www.cloudxns.net/api2/" + +type apiResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +// Data Domain information +type Data struct { + ID string `json:"id"` + Domain string `json:"domain"` + TTL int `json:"ttl,omitempty"` +} + +// TXTRecord a TXT record +type TXTRecord 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"` +} + +// NewClient creates a CloudXNS client +func NewClient(apiKey string, secretKey string) (*Client, error) { + if apiKey == "" { + return nil, fmt.Errorf("CloudXNS: credentials missing: apiKey") + } + + if secretKey == "" { + return nil, fmt.Errorf("CloudXNS: credentials missing: secretKey") + } + + return &Client{ + apiKey: apiKey, + secretKey: secretKey, + HTTPClient: &http.Client{}, + BaseURL: defaultBaseURL, + }, nil +} + +// Client CloudXNS client +type Client struct { + apiKey string + secretKey string + HTTPClient *http.Client + BaseURL string +} + +// GetDomainInformation Get domain name information for a FQDN +func (c *Client) GetDomainInformation(fqdn string) (*Data, error) { + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return nil, err + } + + result, err := c.doRequest(http.MethodGet, "domain", nil) + if err != nil { + return nil, err + } + + var domains []Data + if len(result) > 0 { + err = json.Unmarshal(result, &domains) + if err != nil { + return nil, fmt.Errorf("CloudXNS: domains unmarshaling error: %v", err) + } + } + + for _, data := range domains { + if data.Domain == authZone { + return &data, nil + } + } + + return nil, fmt.Errorf("CloudXNS: zone %s not found for domain %s", authZone, fqdn) +} + +// FindTxtRecord return the TXT record a zone ID and a FQDN +func (c *Client) FindTxtRecord(zoneID, fqdn string) (*TXTRecord, error) { + result, err := c.doRequest(http.MethodGet, fmt.Sprintf("record/%s?host_id=0&offset=0&row_num=2000", zoneID), nil) + if err != nil { + return nil, err + } + + var records []TXTRecord + err = json.Unmarshal(result, &records) + if err != nil { + return nil, fmt.Errorf("CloudXNS: TXT record unmarshaling error: %v", err) + } + + for _, record := range records { + if record.Host == acme.UnFqdn(fqdn) && record.Type == "TXT" { + return &record, nil + } + } + + return nil, fmt.Errorf("CloudXNS: no existing record found for %q", fqdn) +} + +// AddTxtRecord add a TXT record +func (c *Client) AddTxtRecord(info *Data, fqdn, value string, ttl int) error { + id, err := strconv.Atoi(info.ID) + if err != nil { + return fmt.Errorf("CloudXNS: invalid zone ID: %v", err) + } + + payload := TXTRecord{ + ID: id, + Host: acme.UnFqdn(strings.TrimSuffix(fqdn, info.Domain)), + Value: value, + Type: "TXT", + LineID: 1, + TTL: ttl, + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("CloudXNS: record unmarshaling error: %v", err) + } + + _, err = c.doRequest(http.MethodPost, "record", body) + return err +} + +// RemoveTxtRecord remove a TXT record +func (c *Client) RemoveTxtRecord(recordID, zoneID string) error { + _, err := c.doRequest(http.MethodDelete, fmt.Sprintf("record/%s/%s", recordID, zoneID), nil) + return err +} + +func (c *Client) doRequest(method, uri string, body []byte) (json.RawMessage, error) { + req, err := c.buildRequest(method, uri, body) + if err != nil { + return nil, err + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("CloudXNS: %v", err) + } + + defer resp.Body.Close() + + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("CloudXNS: %s", toUnreadableBodyMessage(req, content)) + } + + var r apiResponse + err = json.Unmarshal(content, &r) + if err != nil { + return nil, fmt.Errorf("CloudXNS: response unmashaling error: %v: %s", err, toUnreadableBodyMessage(req, content)) + } + + if r.Code != 1 { + return nil, fmt.Errorf("CloudXNS: invalid code (%v), error: %s", r.Code, r.Message) + } + return r.Data, nil +} + +func (c *Client) buildRequest(method, uri string, body []byte) (*http.Request, error) { + url := c.BaseURL + uri + + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("CloudXNS: invalid request: %v", 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") + + return req, nil +} + +func (c *Client) hmac(url, date, body string) string { + sum := md5.Sum([]byte(c.apiKey + url + body + date + c.secretKey)) + return hex.EncodeToString(sum[:]) +} + +func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string { + return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody)) +} diff --git a/providers/dns/cloudxns/client_test.go b/providers/dns/cloudxns/client_test.go new file mode 100644 index 00000000..5c41c6d7 --- /dev/null +++ b/providers/dns/cloudxns/client_test.go @@ -0,0 +1,283 @@ +package cloudxns + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func handlerMock(method string, response *apiResponse, data interface{}) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + content, err := json.Marshal(apiResponse{ + Code: 999, // random code only for the test + Message: fmt.Sprintf("invalid method: got %s want %s", req.Method, method), + }) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + http.Error(rw, string(content), http.StatusBadRequest) + return + } + + jsonData, err := json.Marshal(data) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + response.Data = jsonData + + content, err := json.Marshal(response) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + rw.Write(content) + }) +} + +func TestClientGetDomainInformation(t *testing.T) { + type result struct { + domain *Data + error bool + } + + testCases := []struct { + desc string + fqdn string + response *apiResponse + data []Data + expected result + }{ + { + desc: "domain found", + fqdn: "_acme-challenge.foo.com.", + response: &apiResponse{ + Code: 1, + }, + data: []Data{ + { + ID: "1", + Domain: "bar.com.", + }, + { + ID: "2", + Domain: "foo.com.", + }, + }, + expected: result{domain: &Data{ + ID: "2", + Domain: "foo.com.", + }}, + }, + { + desc: "domains not found", + fqdn: "_acme-challenge.huu.com.", + response: &apiResponse{ + Code: 1, + }, + data: []Data{ + { + ID: "5", + Domain: "bar.com.", + }, + { + ID: "6", + Domain: "foo.com.", + }, + }, + expected: result{error: true}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + + server := httptest.NewServer(handlerMock(http.MethodGet, test.response, test.data)) + + client, _ := NewClient("myKey", "mySecret") + client.BaseURL = server.URL + "/" + + domain, err := client.GetDomainInformation(test.fqdn) + + if test.expected.error { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected.domain, domain) + } + }) + } +} + +func TestClientFindTxtRecord(t *testing.T) { + type result struct { + txtRecord *TXTRecord + error bool + } + + testCases := []struct { + desc string + fqdn string + zoneID string + txtRecords []TXTRecord + response *apiResponse + expected result + }{ + { + desc: "record found", + fqdn: "_acme-challenge.foo.com.", + zoneID: "test-zone", + txtRecords: []TXTRecord{ + { + ID: 1, + RecordID: "Record-A", + Host: "_acme-challenge.foo.com", + Value: "txtTXTtxtTXTtxtTXTtxtTXT", + Type: "TXT", + LineID: 6, + TTL: 30, + }, + { + ID: 2, + RecordID: "Record-B", + Host: "_acme-challenge.bar.com", + Value: "TXTtxtTXTtxtTXTtxtTXTtxt", + Type: "TXT", + LineID: 6, + TTL: 30, + }, + }, + response: &apiResponse{ + Code: 1, + }, + expected: result{ + txtRecord: &TXTRecord{ + ID: 1, + RecordID: "Record-A", + Host: "_acme-challenge.foo.com", + Value: "txtTXTtxtTXTtxtTXTtxtTXT", + Type: "TXT", + LineID: 6, + TTL: 30, + }, + }, + }, + { + desc: "record not found", + fqdn: "_acme-challenge.huu.com.", + zoneID: "test-zone", + txtRecords: []TXTRecord{ + { + ID: 1, + RecordID: "Record-A", + Host: "_acme-challenge.foo.com", + Value: "txtTXTtxtTXTtxtTXTtxtTXT", + Type: "TXT", + LineID: 6, + TTL: 30, + }, + { + ID: 2, + RecordID: "Record-B", + Host: "_acme-challenge.bar.com", + Value: "TXTtxtTXTtxtTXTtxtTXTtxt", + Type: "TXT", + LineID: 6, + TTL: 30, + }, + }, + response: &apiResponse{ + Code: 1, + }, + expected: result{error: true}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + + server := httptest.NewServer(handlerMock(http.MethodGet, test.response, test.txtRecords)) + + client, _ := NewClient("myKey", "mySecret") + client.BaseURL = server.URL + "/" + + txtRecord, err := client.FindTxtRecord(test.zoneID, test.fqdn) + + if test.expected.error { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected.txtRecord, txtRecord) + } + }) + } +} + +func TestClientAddTxtRecord(t *testing.T) { + testCases := []struct { + desc string + domain *Data + fqdn string + value string + ttl int + expected string + }{ + { + desc: "sub-domain", + domain: &Data{ + ID: "1", + Domain: "bar.com.", + }, + fqdn: "_acme-challenge.foo.bar.com.", + value: "txtTXTtxtTXTtxtTXTtxtTXT", + ttl: 30, + expected: `{"domain_id":1,"host":"_acme-challenge.foo","value":"txtTXTtxtTXTtxtTXTtxtTXT","type":"TXT","line_id":"1","ttl":"30"}`, + }, + { + desc: "main domain", + domain: &Data{ + ID: "2", + Domain: "bar.com.", + }, + fqdn: "_acme-challenge.bar.com.", + value: "TXTtxtTXTtxtTXTtxtTXTtxt", + ttl: 30, + expected: `{"domain_id":2,"host":"_acme-challenge","value":"TXTtxtTXTtxtTXTtxtTXTtxt","type":"TXT","line_id":"1","ttl":"30"}`, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + response := &apiResponse{ + Code: 1, + } + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.NotNil(t, req.Body) + content, err := ioutil.ReadAll(req.Body) + require.NoError(t, err) + + assert.Equal(t, test.expected, string(content)) + + handlerMock(http.MethodPost, response, nil).ServeHTTP(rw, req) + })) + + client, _ := NewClient("myKey", "mySecret") + client.BaseURL = server.URL + "/" + + err := client.AddTxtRecord(test.domain, test.fqdn, test.value, test.ttl) + require.NoError(t, err) + }) + } +} diff --git a/providers/dns/cloudxns/cloudxns.go b/providers/dns/cloudxns/cloudxns.go index ae819f12..4b73564a 100644 --- a/providers/dns/cloudxns/cloudxns.go +++ b/providers/dns/cloudxns/cloudxns.go @@ -1,32 +1,49 @@ // Package cloudxns implements a DNS provider for solving the DNS-01 challenge -// using cloudxns DNS. +// using CloudXNS DNS. package cloudxns import ( - "bytes" - "crypto/md5" - "encoding/hex" - "encoding/json" + "errors" "fmt" "net/http" - "strconv" "time" "github.com/xenolf/lego/acme" "github.com/xenolf/lego/platform/config/env" ) -const cloudXNSBaseURL = "https://www.cloudxns.net/api2/" +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIKey string + SecretKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + client := acme.HTTPClient + client.Timeout = time.Second * time.Duration(env.GetOrDefaultInt("CLOUDXNS_HTTP_TIMEOUT", 30)) + + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond("AKAMAI_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("AKAMAI_POLLING_INTERVAL", acme.DefaultPollingInterval), + TTL: env.GetOrDefaultInt("CLOUDXNS_TTL", 120), + HTTPClient: &client, + } +} // DNSProvider is an implementation of the acme.ChallengeProvider interface type DNSProvider struct { - apiKey string - secretKey string + config *Config + client *Client } -// NewDNSProvider returns a DNSProvider instance configured for cloudxns. -// Credentials must be passed in the environment variables: CLOUDXNS_API_KEY -// and CLOUDXNS_SECRET_KEY. +// 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) { values, err := env.Get("CLOUDXNS_API_KEY", "CLOUDXNS_SECRET_KEY") if err != nil { @@ -37,177 +54,62 @@ func NewDNSProvider() (*DNSProvider, error) { } // NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for cloudxns. +// DNSProvider instance configured for CloudXNS. func NewDNSProviderCredentials(apiKey, secretKey string) (*DNSProvider, error) { - if apiKey == "" || secretKey == "" { - return nil, fmt.Errorf("CloudXNS credentials missing") + config := NewDefaultConfig() + config.APIKey = apiKey + config.SecretKey = secretKey + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for CloudXNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("CloudXNS: the configuration of the DNS provider is nil") } - return &DNSProvider{ - apiKey: apiKey, - secretKey: secretKey, - }, nil + client, err := NewClient(config.APIKey, config.SecretKey) + if err != nil { + return nil, err + } + + client.HTTPClient = config.HTTPClient + + return &DNSProvider{client: client}, nil } // Present creates a TXT record to fulfil the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) - zoneID, err := d.getHostedZoneID(fqdn) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + info, err := d.client.GetDomainInformation(fqdn) if err != nil { return err } - return d.addTxtRecord(zoneID, fqdn, value, ttl) + return d.client.AddTxtRecord(info, fqdn, value, d.config.TTL) } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { fqdn, _, _ := acme.DNS01Record(domain, keyAuth) - zoneID, err := d.getHostedZoneID(fqdn) + + info, err := d.client.GetDomainInformation(fqdn) if err != nil { return err } - recordID, err := d.findTxtRecord(zoneID, fqdn) + record, err := d.client.FindTxtRecord(info.ID, fqdn) if err != nil { return err } - return d.delTxtRecord(recordID, zoneID) + return d.client.RemoveTxtRecord(record.RecordID, info.ID) } -func (d *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 := d.makeRequest(http.MethodGet, "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 (d *DNSProvider) findTxtRecord(zoneID, fqdn string) (string, error) { - result, err := d.makeRequest(http.MethodGet, 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 (d *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 = d.makeRequest(http.MethodPost, "record", body) - return err -} - -func (d *DNSProvider) delTxtRecord(recordID, zoneID string) error { - _, err := d.makeRequest(http.MethodDelete, fmt.Sprintf("record/%s/%s", recordID, zoneID), nil) - return err -} - -func (d *DNSProvider) hmac(url, date, body string) string { - sum := md5.Sum([]byte(d.apiKey + url + body + date + d.secretKey)) - return hex.EncodeToString(sum[:]) -} - -func (d *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", d.apiKey) - req.Header.Set("API-REQUEST-DATE", requestDate) - req.Header.Set("API-HMAC", d.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"` +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval }