From 8b8be6f21e8dfcac43edd1327242001180887b30 Mon Sep 17 00:00:00 2001 From: david <37065472+david-aggeler@users.noreply.github.com> Date: Sun, 28 Feb 2021 13:25:15 +0100 Subject: [PATCH] cloudns: Improve reliability (#1331) Co-authored-by: Fernandez Ludovic --- providers/dns/cloudns/cloudns.go | 38 +- providers/dns/cloudns/internal/client.go | 204 ++++--- providers/dns/cloudns/internal/client_test.go | 527 +++++++++++++++--- providers/dns/cloudns/internal/types.go | 39 ++ 4 files changed, 636 insertions(+), 172 deletions(-) create mode 100644 providers/dns/cloudns/internal/types.go diff --git a/providers/dns/cloudns/cloudns.go b/providers/dns/cloudns/cloudns.go index 575c2e09..ed171952 100644 --- a/providers/dns/cloudns/cloudns.go +++ b/providers/dns/cloudns/cloudns.go @@ -8,7 +8,9 @@ import ( "time" "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/platform/wait" "github.com/go-acme/lego/v4/providers/dns/cloudns/internal" ) @@ -41,8 +43,8 @@ type Config struct { func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, 60), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 180*time.Second), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, @@ -112,10 +114,10 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("ClouDNS: %w", err) } - return nil + return d.waitNameservers(domain, zone) } -// CleanUp removes the TXT record matching the specified parameters. +// CleanUp removes the TXT records matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { fqdn, _ := dns01.GetRecord(domain, keyAuth) @@ -124,19 +126,22 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("ClouDNS: %w", err) } - record, err := d.client.FindTxtRecord(zone.Name, fqdn) + records, err := d.client.ListTxtRecords(zone.Name, fqdn) if err != nil { return fmt.Errorf("ClouDNS: %w", err) } - if record == nil { + if len(records) == 0 { return nil } - err = d.client.RemoveTxtRecord(record.ID, zone.Name) - if err != nil { - return fmt.Errorf("ClouDNS: %w", err) + for _, record := range records { + err = d.client.RemoveTxtRecord(record.ID, zone.Name) + if err != nil { + return fmt.Errorf("ClouDNS: %w", err) + } } + return nil } @@ -145,3 +150,18 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } + +// waitNameservers At the time of writing 4 servers are found as authoritative, but 8 are reported during the sync. +// If this is not done, the secondary verification done by Let's Encrypt server will fail quire a bit. +func (d *DNSProvider) waitNameservers(domain string, zone *internal.Zone) error { + return wait.For("Nameserver sync on "+domain, d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) { + syncProgress, err := d.client.GetUpdateStatus(zone.Name) + if err != nil { + return false, err + } + + log.Infof("[%s] Sync %d/%d complete", domain, syncProgress.Updated, syncProgress.Total) + + return syncProgress.Complete, nil + }) +} diff --git a/providers/dns/cloudns/internal/client.go b/providers/dns/cloudns/internal/client.go index 931118d8..83645d25 100644 --- a/providers/dns/cloudns/internal/client.go +++ b/providers/dns/cloudns/internal/client.go @@ -7,6 +7,7 @@ import ( "io/ioutil" "net/http" "net/url" + "path" "strconv" "strings" @@ -15,31 +16,15 @@ import ( const defaultBaseURL = "https://api.cloudns.net/dns/" -type apiResponse struct { - Status string `json:"status"` - StatusDescription string `json:"statusDescription"` +// Client the ClouDNS client. +type Client struct { + authID string + subAuthID string + authPassword string + HTTPClient *http.Client + BaseURL *url.URL } -type Zone struct { - Name string - Type string - Zone string - Status string // is an integer, but cast as string -} - -// TXTRecord a TXT record. -type TXTRecord struct { - ID int `json:"id,string"` - Type string `json:"type"` - Host string `json:"host"` - Record string `json:"record"` - Failover int `json:"failover,string"` - TTL int `json:"ttl,string"` - Status int `json:"status"` -} - -type TXTRecords map[string]TXTRecord - // NewClient creates a ClouDNS client. func NewClient(authID, subAuthID, authPassword string) (*Client, error) { if authID == "" && subAuthID == "" { @@ -64,15 +49,6 @@ func NewClient(authID, subAuthID, authPassword string) (*Client, error) { }, nil } -// Client ClouDNS client. -type Client struct { - authID string - subAuthID string - authPassword string - HTTPClient *http.Client - BaseURL *url.URL -} - // GetZone Get domain name information for a FQDN. func (c *Client) GetZone(authFQDN string) (*Zone, error) { authZone, err := dns01.FindZoneByFqdn(authFQDN) @@ -82,14 +58,16 @@ func (c *Client) GetZone(authFQDN string) (*Zone, error) { authZoneName := dns01.UnFqdn(authZone) - reqURL := *c.BaseURL - reqURL.Path += "get-zone-info.json" + endpoint, err := c.BaseURL.Parse(path.Join(c.BaseURL.Path, "get-zone-info.json")) + if err != nil { + return nil, fmt.Errorf("failed to parse endpoint: %w", err) + } - q := reqURL.Query() - q.Add("domain-name", authZoneName) - reqURL.RawQuery = q.Encode() + q := endpoint.Query() + q.Set("domain-name", authZoneName) + endpoint.RawQuery = q.Encode() - result, err := c.doRequest(http.MethodGet, &reqURL) + result, err := c.doRequest(http.MethodGet, endpoint) if err != nil { return nil, err } @@ -98,7 +76,7 @@ func (c *Client) GetZone(authFQDN string) (*Zone, error) { if len(result) > 0 { if err = json.Unmarshal(result, &zone); err != nil { - return nil, fmt.Errorf("zone unmarshaling error: %w", err) + return nil, fmt.Errorf("failed to unmarshal zone: %w", err) } } @@ -109,20 +87,22 @@ func (c *Client) GetZone(authFQDN string) (*Zone, error) { return nil, fmt.Errorf("zone %s not found for authFQDN %s", authZoneName, authFQDN) } -// FindTxtRecord return the TXT record a zone ID and a FQDN. +// FindTxtRecord returns the TXT record a zone ID and a FQDN. func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) { host := dns01.UnFqdn(strings.TrimSuffix(dns01.UnFqdn(fqdn), zoneName)) - reqURL := *c.BaseURL - reqURL.Path += "records.json" + reqURL, err := c.BaseURL.Parse(path.Join(c.BaseURL.Path, "records.json")) + if err != nil { + return nil, fmt.Errorf("failed to parse endpoint: %w", err) + } q := reqURL.Query() - q.Add("domain-name", zoneName) - q.Add("host", host) - q.Add("type", "TXT") + q.Set("domain-name", zoneName) + q.Set("host", host) + q.Set("type", "TXT") reqURL.RawQuery = q.Encode() - result, err := c.doRequest(http.MethodGet, &reqURL) + result, err := c.doRequest(http.MethodGet, reqURL) if err != nil { return nil, err } @@ -132,9 +112,9 @@ func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) { return nil, nil } - var records TXTRecords + var records map[string]TXTRecord if err = json.Unmarshal(result, &records); err != nil { - return nil, fmt.Errorf("TXT record unmarshaling error: %w: %s", err, string(result)) + return nil, fmt.Errorf("failed to unmarshall TXT records: %w: %s", err, string(result)) } for _, record := range records { @@ -146,65 +126,145 @@ func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) { return nil, nil } -// AddTxtRecord add a TXT record. +// ListTxtRecords returns the TXT records a zone ID and a FQDN. +func (c *Client) ListTxtRecords(zoneName, fqdn string) ([]TXTRecord, error) { + host := dns01.UnFqdn(strings.TrimSuffix(dns01.UnFqdn(fqdn), zoneName)) + + reqURL, err := c.BaseURL.Parse(path.Join(c.BaseURL.Path, "records.json")) + if err != nil { + return nil, fmt.Errorf("failed to parse endpoint: %w", err) + } + + q := reqURL.Query() + q.Set("domain-name", zoneName) + q.Set("host", host) + q.Set("type", "TXT") + reqURL.RawQuery = q.Encode() + + result, err := c.doRequest(http.MethodGet, reqURL) + if err != nil { + return nil, err + } + + // the API returns [] when there is no records. + if string(result) == "[]" { + return nil, nil + } + + var raw map[string]TXTRecord + if err = json.Unmarshal(result, &raw); err != nil { + return nil, fmt.Errorf("failed to unmarshall TXT records: %w: %s", err, string(result)) + } + + var records []TXTRecord + for _, record := range raw { + if record.Host == host && record.Type == "TXT" { + records = append(records, record) + } + } + + return records, nil +} + +// AddTxtRecord adds a TXT record. func (c *Client) AddTxtRecord(zoneName, fqdn, value string, ttl int) error { host := dns01.UnFqdn(strings.TrimSuffix(dns01.UnFqdn(fqdn), zoneName)) - reqURL := *c.BaseURL - reqURL.Path += "add-record.json" + reqURL, err := c.BaseURL.Parse(path.Join(c.BaseURL.Path, "add-record.json")) + if err != nil { + return fmt.Errorf("failed to parse endpoint: %w", err) + } q := reqURL.Query() - q.Add("domain-name", zoneName) - q.Add("host", host) - q.Add("record", value) - q.Add("ttl", strconv.Itoa(ttlRounder(ttl))) - q.Add("record-type", "TXT") + q.Set("domain-name", zoneName) + q.Set("host", host) + q.Set("record", value) + q.Set("ttl", strconv.Itoa(ttlRounder(ttl))) + q.Set("record-type", "TXT") reqURL.RawQuery = q.Encode() - raw, err := c.doRequest(http.MethodPost, &reqURL) + raw, err := c.doRequest(http.MethodPost, reqURL) if err != nil { return err } resp := apiResponse{} if err = json.Unmarshal(raw, &resp); err != nil { - return fmt.Errorf("apiResponse unmarshaling error: %w: %s", err, string(raw)) + return fmt.Errorf("failed to unmarshal API response: %w: %s", err, string(raw)) } if resp.Status != "Success" { - return fmt.Errorf("fail to add TXT record: %s %s", resp.Status, resp.StatusDescription) + return fmt.Errorf("failed to add TXT record: %s %s", resp.Status, resp.StatusDescription) } return nil } -// RemoveTxtRecord remove a TXT record. +// RemoveTxtRecord removes a TXT record. func (c *Client) RemoveTxtRecord(recordID int, zoneName string) error { - reqURL := *c.BaseURL - reqURL.Path += "delete-record.json" + reqURL, err := c.BaseURL.Parse(path.Join(c.BaseURL.Path, "delete-record.json")) + if err != nil { + return fmt.Errorf("failed to parse endpoint: %w", err) + } q := reqURL.Query() - q.Add("domain-name", zoneName) - q.Add("record-id", strconv.Itoa(recordID)) + q.Set("domain-name", zoneName) + q.Set("record-id", strconv.Itoa(recordID)) reqURL.RawQuery = q.Encode() - raw, err := c.doRequest(http.MethodPost, &reqURL) + raw, err := c.doRequest(http.MethodPost, reqURL) if err != nil { return err } resp := apiResponse{} if err = json.Unmarshal(raw, &resp); err != nil { - return fmt.Errorf("apiResponse unmarshaling error: %w: %s", err, string(raw)) + return fmt.Errorf("failed to unmarshal API response: %w: %s", err, string(raw)) } if resp.Status != "Success" { - return fmt.Errorf("fail to add TXT record: %s %s", resp.Status, resp.StatusDescription) + return fmt.Errorf("failed to remove TXT record: %s %s", resp.Status, resp.StatusDescription) } return nil } +// GetUpdateStatus gets sync progress of all CloudDNS NS servers. +func (c *Client) GetUpdateStatus(zoneName string) (*SyncProgress, error) { + reqURL, err := c.BaseURL.Parse(path.Join(c.BaseURL.Path, "update-status.json")) + if err != nil { + return nil, fmt.Errorf("failed to parse endpoint: %w", err) + } + + q := reqURL.Query() + q.Set("domain-name", zoneName) + reqURL.RawQuery = q.Encode() + + result, err := c.doRequest(http.MethodGet, reqURL) + if err != nil { + return nil, err + } + + // the API returns [] when there is no records. + if string(result) == "[]" { + return nil, errors.New("no nameservers records returned") + } + + var records []UpdateRecord + if err = json.Unmarshal(result, &records); err != nil { + return nil, fmt.Errorf("failed to unmarshal UpdateRecord: %w: %s", err, string(result)) + } + + updatedCount := 0 + for _, record := range records { + if record.Updated { + updatedCount++ + } + } + + return &SyncProgress{Complete: updatedCount == len(records), Updated: updatedCount, Total: len(records)}, nil +} + func (c *Client) doRequest(method string, url *url.URL) (json.RawMessage, error) { req, err := c.buildRequest(method, url) if err != nil { @@ -224,8 +284,9 @@ func (c *Client) doRequest(method string, url *url.URL) (json.RawMessage, error) } if resp.StatusCode != 200 { - return nil, fmt.Errorf("invalid code (%v), error: %s", resp.StatusCode, content) + return nil, fmt.Errorf("invalid code (%d), error: %s", resp.StatusCode, content) } + return content, nil } @@ -233,12 +294,12 @@ func (c *Client) buildRequest(method string, url *url.URL) (*http.Request, error q := url.Query() if c.subAuthID != "" { - q.Add("sub-auth-id", c.subAuthID) + q.Set("sub-auth-id", c.subAuthID) } else { - q.Add("auth-id", c.authID) + q.Set("auth-id", c.authID) } - q.Add("auth-password", c.authPassword) + q.Set("auth-password", c.authPassword) url.RawQuery = q.Encode() @@ -269,7 +330,6 @@ func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string { // - 604800 = 1 week // - 1209600 = 2 weeks // - 2592000 = 1 month -// - 2592000 = 1 month // See https://www.cloudns.net/wiki/article/58/ for details. func ttlRounder(ttl int) int { for _, validTTL := range []int{60, 300, 900, 1800, 3600, 21600, 43200, 86400, 172800, 259200, 604800, 1209600} { diff --git a/providers/dns/cloudns/internal/client_test.go b/providers/dns/cloudns/internal/client_test.go index 5575cccf..5731833c 100644 --- a/providers/dns/cloudns/internal/client_test.go +++ b/providers/dns/cloudns/internal/client_test.go @@ -11,8 +11,8 @@ import ( "github.com/stretchr/testify/require" ) -func handlerMock(method string, jsonData []byte) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { +func handlerMock(method string, jsonData []byte) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { if req.Method != method { http.Error(rw, "Incorrect method used", http.StatusBadRequest) return @@ -23,25 +23,71 @@ func handlerMock(method string, jsonData []byte) http.Handler { http.Error(rw, err.Error(), http.StatusInternalServerError) return } - }) + } } -func TestClientGetZone(t *testing.T) { - type result struct { - zone *Zone - error bool +func TestNewClient(t *testing.T) { + testCases := []struct { + desc string + authID string + subAuthID string + authPassword string + expected string + }{ + { + desc: "all provided", + authID: "1000", + subAuthID: "1111", + authPassword: "no-secret", + }, + { + desc: "missing authID & subAuthID", + authID: "", + subAuthID: "", + authPassword: "no-secret", + expected: "credentials missing: authID or subAuthID", + }, + { + desc: "missing authID & subAuthID", + authID: "", + subAuthID: "present", + authPassword: "", + expected: "credentials missing: authPassword", + }, } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + client, err := NewClient(test.authID, test.subAuthID, test.authPassword) + + if test.expected != "" { + assert.Nil(t, client) + require.EqualError(t, err, test.expected) + } else { + assert.NotNil(t, client) + require.NoError(t, err) + } + }) + } +} + +func TestClient_GetZone(t *testing.T) { + type expected struct { + zone *Zone + errorMsg string + } + testCases := []struct { desc string authFQDN string - apiResponse []byte - expected result + apiResponse string + expected }{ { desc: "zone found", authFQDN: "_acme-challenge.foo.com.", - apiResponse: []byte(`{"name": "foo.com", "type": "master", "zone": "zone", "status": "1"}`), - expected: result{ + apiResponse: `{"name": "foo.com", "type": "master", "zone": "zone", "status": "1"}`, + expected: expected{ zone: &Zone{ Name: "foo.com", Type: "master", @@ -53,23 +99,35 @@ func TestClientGetZone(t *testing.T) { { desc: "zone not found", authFQDN: "_acme-challenge.foo.com.", - apiResponse: []byte(``), - expected: result{error: true}, + apiResponse: ``, + expected: expected{ + errorMsg: "zone foo.com not found for authFQDN _acme-challenge.foo.com.", + }, + }, + { + desc: "invalid json response", + authFQDN: "_acme-challenge.foo.com.", + apiResponse: `[{}]`, + expected: expected{ + errorMsg: "failed to unmarshal zone: json: cannot unmarshal array into Go value of type internal.Zone", + }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - server := httptest.NewServer(handlerMock(http.MethodGet, test.apiResponse)) + server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse))) + t.Cleanup(server.Close) - client, _ := NewClient("myAuthID", "", "myAuthPassword") - mockBaseURL, _ := url.Parse(fmt.Sprintf("%s/", server.URL)) - client.BaseURL = mockBaseURL + client, err := NewClient("myAuthID", "", "myAuthPassword") + require.NoError(t, err) + + client.BaseURL, _ = url.Parse(server.URL) zone, err := client.GetZone(test.authFQDN) - if test.expected.error { - require.Error(t, err) + if test.expected.errorMsg != "" { + require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) assert.Equal(t, test.expected.zone, zone) @@ -78,24 +136,24 @@ func TestClientGetZone(t *testing.T) { } } -func TestClientFindTxtRecord(t *testing.T) { - type result struct { +func TestClient_FindTxtRecord(t *testing.T) { + type expected struct { txtRecord *TXTRecord - error bool + errorMsg string } testCases := []struct { desc string authFQDN string zoneName string - apiResponse []byte - expected result + apiResponse string + expected }{ { desc: "record found", authFQDN: "_acme-challenge.foo.com.", zoneName: "foo.com", - apiResponse: []byte(`{ + apiResponse: `{ "5769228": { "id": "5769228", "type": "TXT", @@ -114,8 +172,8 @@ func TestClientFindTxtRecord(t *testing.T) { "ttl": "300", "status": 1 } -}`), - expected: result{ +}`, + expected: expected{ txtRecord: &TXTRecord{ ID: 5769228, Type: "TXT", @@ -128,28 +186,61 @@ func TestClientFindTxtRecord(t *testing.T) { }, }, { - desc: "record not found", + desc: "no record found", + authFQDN: "_acme-challenge.foo.com.", + zoneName: "foo.com", + apiResponse: `{ + "5769228": { + "id": "5769228", + "type": "TXT", + "host": "_other-challenge", + "record": "txtTXTtxtTXTtxtTXTtxtTXT", + "failover": "0", + "ttl": "3600", + "status": 1 + }, + "181805209": { + "id": "181805209", + "type": "TXT", + "host": "_github-challenge", + "record": "b66b8324b5", + "failover": "0", + "ttl": "300", + "status": 1 + } +}`, + }, + { + desc: "zero records", authFQDN: "_acme-challenge.foo.com.", zoneName: "test-zone", - apiResponse: []byte(`[]`), - expected: result{txtRecord: nil}, + apiResponse: `[]`, + }, + { + desc: "invalid json response", + authFQDN: "_acme-challenge.foo.com.", + zoneName: "test-zone", + apiResponse: `[{}]`, + expected: expected{ + errorMsg: "failed to unmarshall TXT records: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord: [{}]", + }, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - server := httptest.NewServer(handlerMock(http.MethodGet, test.apiResponse)) + server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse))) + t.Cleanup(server.Close) client, err := NewClient("myAuthID", "", "myAuthPassword") require.NoError(t, err) - mockBaseURL, _ := url.Parse(fmt.Sprintf("%s/", server.URL)) - client.BaseURL = mockBaseURL + client.BaseURL, _ = url.Parse(server.URL) txtRecord, err := client.FindTxtRecord(test.zoneName, test.authFQDN) - if test.expected.error { - require.Error(t, err) + if test.expected.errorMsg != "" { + require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) assert.Equal(t, test.expected.txtRecord, txtRecord) @@ -158,91 +249,198 @@ func TestClientFindTxtRecord(t *testing.T) { } } -func TestClientAddTxtRecord(t *testing.T) { +func TestClient_ListTxtRecord(t *testing.T) { type expected struct { - Query string - Error string + txtRecords []TXTRecord + errorMsg string + } + + testCases := []struct { + desc string + authFQDN string + zoneName string + apiResponse string + expected + }{ + { + desc: "record found", + authFQDN: "_acme-challenge.foo.com.", + zoneName: "foo.com", + apiResponse: `{ + "5769228": { + "id": "5769228", + "type": "TXT", + "host": "_acme-challenge", + "record": "txtTXTtxtTXTtxtTXTtxtTXT", + "failover": "0", + "ttl": "3600", + "status": 1 + }, + "181805209": { + "id": "181805209", + "type": "TXT", + "host": "_github-challenge", + "record": "b66b8324b5", + "failover": "0", + "ttl": "300", + "status": 1 + } +}`, + expected: expected{ + txtRecords: []TXTRecord{ + { + ID: 5769228, + Type: "TXT", + Host: "_acme-challenge", + Record: "txtTXTtxtTXTtxtTXTtxtTXT", + Failover: 0, + TTL: 3600, + Status: 1, + }, + }, + }, + }, + { + desc: "no record found", + authFQDN: "_acme-challenge.foo.com.", + zoneName: "foo.com", + apiResponse: `{ + "5769228": { + "id": "5769228", + "type": "TXT", + "host": "_other-challenge", + "record": "txtTXTtxtTXTtxtTXTtxtTXT", + "failover": "0", + "ttl": "3600", + "status": 1 + }, + "181805209": { + "id": "181805209", + "type": "TXT", + "host": "_github-challenge", + "record": "b66b8324b5", + "failover": "0", + "ttl": "300", + "status": 1 + } +}`, + }, + { + desc: "zero records", + authFQDN: "_acme-challenge.foo.com.", + zoneName: "test-zone", + apiResponse: `[]`, + }, + { + desc: "invalid json response", + authFQDN: "_acme-challenge.foo.com.", + zoneName: "test-zone", + apiResponse: `[{}]`, + expected: expected{ + errorMsg: "failed to unmarshall TXT records: json: cannot unmarshal array into Go value of type map[string]internal.TXTRecord: [{}]", + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse))) + t.Cleanup(server.Close) + + client, err := NewClient("myAuthID", "", "myAuthPassword") + require.NoError(t, err) + + client.BaseURL, _ = url.Parse(server.URL) + + txtRecords, err := client.ListTxtRecords(test.zoneName, test.authFQDN) + + if test.expected.errorMsg != "" { + require.EqualError(t, err, test.expected.errorMsg) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected.txtRecords, txtRecords) + } + }) + } +} + +func TestClient_AddTxtRecord(t *testing.T) { + type expected struct { + query string + errorMsg string } testCases := []struct { desc string authID string subAuthID string - zone *Zone + zoneName string authFQDN string value string ttl int - apiResponse []byte - expected expected + apiResponse string + expected }{ { - desc: "sub-zone", - authID: "myAuthID", - zone: &Zone{ - Name: "bar.com", - Type: "master", - Zone: "domain", - Status: "1", - }, + desc: "sub-zone", + authID: "myAuthID", + zoneName: "bar.com", authFQDN: "_acme-challenge.foo.bar.com.", value: "txtTXTtxtTXTtxtTXTtxtTXT", ttl: 60, - apiResponse: []byte(`{"status":"Success","statusDescription":"The record was added successfully."}`), + apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ - Query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge.foo&record=txtTXTtxtTXTtxtTXTtxtTXT&record-type=TXT&ttl=60`, + query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge.foo&record=txtTXTtxtTXTtxtTXTtxtTXT&record-type=TXT&ttl=60`, }, }, { - desc: "main zone (authID)", - authID: "myAuthID", - zone: &Zone{ - Name: "bar.com", - Type: "master", - Zone: "domain", - Status: "1", - }, + desc: "main zone (authID)", + authID: "myAuthID", + zoneName: "bar.com", authFQDN: "_acme-challenge.bar.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 60, - apiResponse: []byte(`{"status":"Success","statusDescription":"The record was added successfully."}`), + apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ - Query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=60`, + query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=60`, }, }, { - desc: "main zone (subAuthID)", - authID: "myAuthID", - subAuthID: "mySubAuthID", - zone: &Zone{ - Name: "bar.com", - Type: "master", - Zone: "domain", - Status: "1", - }, + desc: "main zone (subAuthID)", + subAuthID: "mySubAuthID", + zoneName: "bar.com", authFQDN: "_acme-challenge.bar.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 60, - apiResponse: []byte(`{"status":"Success","statusDescription":"The record was added successfully."}`), + apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`, expected: expected{ - Query: `auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&sub-auth-id=mySubAuthID&ttl=60`, + query: `auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&sub-auth-id=mySubAuthID&ttl=60`, }, }, { - desc: "invalid status", - authID: "myAuthID", - zone: &Zone{ - Name: "bar.com", - Type: "master", - Zone: "domain", - Status: "1", - }, + desc: "invalid status", + authID: "myAuthID", + zoneName: "bar.com", authFQDN: "_acme-challenge.bar.com.", value: "TXTtxtTXTtxtTXTtxtTXTtxt", ttl: 120, - apiResponse: []byte(`{"status":"Failed","statusDescription":"Invalid TTL. Choose from the list of the values we support."}`), + apiResponse: `{"status":"Failed","statusDescription":"Invalid TTL. Choose from the list of the values we support."}`, expected: expected{ - Query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, - Error: "fail to add TXT record: Failed Invalid TTL. Choose from the list of the values we support.", + query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, + errorMsg: "failed to add TXT record: Failed Invalid TTL. Choose from the list of the values we support.", + }, + }, + { + desc: "invalid json response", + authID: "myAuthID", + zoneName: "bar.com", + authFQDN: "_acme-challenge.bar.com.", + value: "TXTtxtTXTtxtTXTtxtTXTtxt", + ttl: 120, + apiResponse: `[{}]`, + expected: expected{ + query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, + errorMsg: "failed to unmarshal API response: json: cannot unmarshal array into Go value of type internal.apiResponse: [{}]", }, }, } @@ -250,25 +448,172 @@ func TestClientAddTxtRecord(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - assert.NotNil(t, req.URL.RawQuery) - assert.Equal(t, test.expected.Query, req.URL.RawQuery) + if test.expected.query != req.URL.RawQuery { + msg := fmt.Sprintf("got: %s, want: %s", test.expected.query, req.URL.RawQuery) + http.Error(rw, msg, http.StatusBadRequest) + return + } - handlerMock(http.MethodPost, test.apiResponse).ServeHTTP(rw, req) + handlerMock(http.MethodPost, []byte(test.apiResponse))(rw, req) })) client, err := NewClient(test.authID, test.subAuthID, "myAuthPassword") require.NoError(t, err) - mockBaseURL, _ := url.Parse(fmt.Sprintf("%s/", server.URL)) - client.BaseURL = mockBaseURL + client.BaseURL, _ = url.Parse(server.URL) - err = client.AddTxtRecord(test.zone.Name, test.authFQDN, test.value, test.ttl) + err = client.AddTxtRecord(test.zoneName, test.authFQDN, test.value, test.ttl) - if test.expected.Error != "" { - require.EqualError(t, err, test.expected.Error) + if test.expected.errorMsg != "" { + require.EqualError(t, err, test.expected.errorMsg) } else { require.NoError(t, err) } }) } } + +func TestClient_RemoveTxtRecord(t *testing.T) { + type expected struct { + query string + errorMsg string + } + + testCases := []struct { + desc string + id int + zoneName string + apiResponse string + expected + }{ + { + desc: "record found", + id: 5769228, + zoneName: "foo.com", + apiResponse: `{ "status": "Success", "statusDescription": "The record was deleted successfully." }`, + expected: expected{ + query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo.com&record-id=5769228`, + }, + }, + { + desc: "record not found", + id: 5769000, + zoneName: "foo.com", + apiResponse: `{ "status": "Failed", "statusDescription": "Invalid record-id param." }`, + expected: expected{ + query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo.com&record-id=5769000`, + errorMsg: "failed to remove TXT record: Failed Invalid record-id param.", + }, + }, + { + desc: "invalid json response", + id: 44, + zoneName: "foo-plus.com", + apiResponse: `[{}]`, + expected: expected{ + query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=foo-plus.com&record-id=44`, + errorMsg: "failed to unmarshal API response: json: cannot unmarshal array into Go value of type internal.apiResponse: [{}]", + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if test.expected.query != req.URL.RawQuery { + msg := fmt.Sprintf("got: %s, want: %s", test.expected.query, req.URL.RawQuery) + http.Error(rw, msg, http.StatusBadRequest) + return + } + + handlerMock(http.MethodPost, []byte(test.apiResponse))(rw, req) + })) + t.Cleanup(server.Close) + + client, err := NewClient("myAuthID", "", "myAuthPassword") + require.NoError(t, err) + + client.BaseURL, _ = url.Parse(server.URL) + + err = client.RemoveTxtRecord(test.id, test.zoneName) + + if test.expected.errorMsg != "" { + require.EqualError(t, err, test.expected.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestClient_GetUpdateStatus(t *testing.T) { + type expected struct { + progress *SyncProgress + errorMsg string + } + + testCases := []struct { + desc string + authFQDN string + zoneName string + apiResponse string + expected + }{ + { + desc: "50% sync", + authFQDN: "_acme-challenge.foo.com.", + zoneName: "foo.com", + apiResponse: `[ +{"server": "ns101.foo.com.", "ip4": "10.11.12.13", "ip6": "2a00:2a00:2a00:9::5", "updated": true }, +{"server": "ns102.foo.com.", "ip4": "10.14.16.17", "ip6": "2100:2100:2100:3::1", "updated": false } +]`, + expected: expected{progress: &SyncProgress{Updated: 1, Total: 2}}, + }, + { + desc: "100% sync", + authFQDN: "_acme-challenge.foo.com.", + zoneName: "foo.com", + apiResponse: `[ +{"server": "ns101.foo.com.", "ip4": "10.11.12.13", "ip6": "2a00:2a00:2a00:9::5", "updated": true }, +{"server": "ns102.foo.com.", "ip4": "10.14.16.17", "ip6": "2100:2100:2100:3::1", "updated": true } +]`, + expected: expected{progress: &SyncProgress{Complete: true, Updated: 2, Total: 2}}, + }, + { + desc: "record not found", + authFQDN: "_acme-challenge.foo.com.", + zoneName: "test-zone", + apiResponse: `[]`, + expected: expected{errorMsg: "no nameservers records returned"}, + }, + { + desc: "invalid json response", + authFQDN: "_acme-challenge.foo.com.", + zoneName: "test-zone", + apiResponse: `[x]`, + expected: expected{errorMsg: "failed to unmarshal UpdateRecord: invalid character 'x' looking for beginning of value: [x]"}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + server := httptest.NewServer(handlerMock(http.MethodGet, []byte(test.apiResponse))) + t.Cleanup(server.Close) + + client, err := NewClient("myAuthID", "", "myAuthPassword") + require.NoError(t, err) + + client.BaseURL, _ = url.Parse(server.URL) + + syncProgress, err := client.GetUpdateStatus(test.zoneName) + + if test.expected.errorMsg != "" { + require.EqualError(t, err, test.expected.errorMsg) + } else { + require.NoError(t, err) + } + + assert.Equal(t, test.expected.progress, syncProgress) + }) + } +} diff --git a/providers/dns/cloudns/internal/types.go b/providers/dns/cloudns/internal/types.go new file mode 100644 index 00000000..9c0bf8d9 --- /dev/null +++ b/providers/dns/cloudns/internal/types.go @@ -0,0 +1,39 @@ +package internal + +type apiResponse struct { + Status string `json:"status"` + StatusDescription string `json:"statusDescription"` +} + +// Zone is a zone. +type Zone struct { + Name string + Type string + Zone string + Status string // is an integer, but cast as string +} + +// TXTRecord is a TXT record. +type TXTRecord struct { + ID int `json:"id,string"` + Type string `json:"type"` + Host string `json:"host"` + Record string `json:"record"` + Failover int `json:"failover,string"` + TTL int `json:"ttl,string"` + Status int `json:"status"` +} + +// UpdateRecord is a Server Sync Record. +type UpdateRecord struct { + Server string `json:"server"` + IP4 string `json:"ip4"` + IP6 string `json:"ip6"` + Updated bool `json:"updated"` +} + +type SyncProgress struct { + Complete bool + Updated int + Total int +}