cloudns: Improve reliability (#1331)

Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
This commit is contained in:
david 2021-02-28 13:25:15 +01:00 committed by GitHub
parent b65dfb8661
commit 8b8be6f21e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 636 additions and 172 deletions

View file

@ -8,7 +8,9 @@ import (
"time" "time"
"github.com/go-acme/lego/v4/challenge/dns01" "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/config/env"
"github.com/go-acme/lego/v4/platform/wait"
"github.com/go-acme/lego/v4/providers/dns/cloudns/internal" "github.com/go-acme/lego/v4/providers/dns/cloudns/internal"
) )
@ -41,8 +43,8 @@ type Config struct {
func NewDefaultConfig() *Config { func NewDefaultConfig() *Config {
return &Config{ return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, 60), TTL: env.GetOrDefaultInt(EnvTTL, 60),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 180*time.Second),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second),
HTTPClient: &http.Client{ HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), 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 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 { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _ := dns01.GetRecord(domain, keyAuth) fqdn, _ := dns01.GetRecord(domain, keyAuth)
@ -124,19 +126,22 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return fmt.Errorf("ClouDNS: %w", err) 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 { if err != nil {
return fmt.Errorf("ClouDNS: %w", err) return fmt.Errorf("ClouDNS: %w", err)
} }
if record == nil { if len(records) == 0 {
return nil return nil
} }
err = d.client.RemoveTxtRecord(record.ID, zone.Name) for _, record := range records {
if err != nil { err = d.client.RemoveTxtRecord(record.ID, zone.Name)
return fmt.Errorf("ClouDNS: %w", err) if err != nil {
return fmt.Errorf("ClouDNS: %w", err)
}
} }
return nil return nil
} }
@ -145,3 +150,18 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval 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
})
}

View file

@ -7,6 +7,7 @@ import (
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
"path"
"strconv" "strconv"
"strings" "strings"
@ -15,31 +16,15 @@ import (
const defaultBaseURL = "https://api.cloudns.net/dns/" const defaultBaseURL = "https://api.cloudns.net/dns/"
type apiResponse struct { // Client the ClouDNS client.
Status string `json:"status"` type Client struct {
StatusDescription string `json:"statusDescription"` 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. // NewClient creates a ClouDNS client.
func NewClient(authID, subAuthID, authPassword string) (*Client, error) { func NewClient(authID, subAuthID, authPassword string) (*Client, error) {
if authID == "" && subAuthID == "" { if authID == "" && subAuthID == "" {
@ -64,15 +49,6 @@ func NewClient(authID, subAuthID, authPassword string) (*Client, error) {
}, nil }, 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. // GetZone Get domain name information for a FQDN.
func (c *Client) GetZone(authFQDN string) (*Zone, error) { func (c *Client) GetZone(authFQDN string) (*Zone, error) {
authZone, err := dns01.FindZoneByFqdn(authFQDN) authZone, err := dns01.FindZoneByFqdn(authFQDN)
@ -82,14 +58,16 @@ func (c *Client) GetZone(authFQDN string) (*Zone, error) {
authZoneName := dns01.UnFqdn(authZone) authZoneName := dns01.UnFqdn(authZone)
reqURL := *c.BaseURL endpoint, err := c.BaseURL.Parse(path.Join(c.BaseURL.Path, "get-zone-info.json"))
reqURL.Path += "get-zone-info.json" if err != nil {
return nil, fmt.Errorf("failed to parse endpoint: %w", err)
}
q := reqURL.Query() q := endpoint.Query()
q.Add("domain-name", authZoneName) q.Set("domain-name", authZoneName)
reqURL.RawQuery = q.Encode() endpoint.RawQuery = q.Encode()
result, err := c.doRequest(http.MethodGet, &reqURL) result, err := c.doRequest(http.MethodGet, endpoint)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -98,7 +76,7 @@ func (c *Client) GetZone(authFQDN string) (*Zone, error) {
if len(result) > 0 { if len(result) > 0 {
if err = json.Unmarshal(result, &zone); err != nil { 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) 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) { func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) {
host := dns01.UnFqdn(strings.TrimSuffix(dns01.UnFqdn(fqdn), zoneName)) host := dns01.UnFqdn(strings.TrimSuffix(dns01.UnFqdn(fqdn), zoneName))
reqURL := *c.BaseURL reqURL, err := c.BaseURL.Parse(path.Join(c.BaseURL.Path, "records.json"))
reqURL.Path += "records.json" if err != nil {
return nil, fmt.Errorf("failed to parse endpoint: %w", err)
}
q := reqURL.Query() q := reqURL.Query()
q.Add("domain-name", zoneName) q.Set("domain-name", zoneName)
q.Add("host", host) q.Set("host", host)
q.Add("type", "TXT") q.Set("type", "TXT")
reqURL.RawQuery = q.Encode() reqURL.RawQuery = q.Encode()
result, err := c.doRequest(http.MethodGet, &reqURL) result, err := c.doRequest(http.MethodGet, reqURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -132,9 +112,9 @@ func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) {
return nil, nil return nil, nil
} }
var records TXTRecords var records map[string]TXTRecord
if err = json.Unmarshal(result, &records); err != nil { 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 { for _, record := range records {
@ -146,65 +126,145 @@ func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) {
return nil, nil 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 { func (c *Client) AddTxtRecord(zoneName, fqdn, value string, ttl int) error {
host := dns01.UnFqdn(strings.TrimSuffix(dns01.UnFqdn(fqdn), zoneName)) host := dns01.UnFqdn(strings.TrimSuffix(dns01.UnFqdn(fqdn), zoneName))
reqURL := *c.BaseURL reqURL, err := c.BaseURL.Parse(path.Join(c.BaseURL.Path, "add-record.json"))
reqURL.Path += "add-record.json" if err != nil {
return fmt.Errorf("failed to parse endpoint: %w", err)
}
q := reqURL.Query() q := reqURL.Query()
q.Add("domain-name", zoneName) q.Set("domain-name", zoneName)
q.Add("host", host) q.Set("host", host)
q.Add("record", value) q.Set("record", value)
q.Add("ttl", strconv.Itoa(ttlRounder(ttl))) q.Set("ttl", strconv.Itoa(ttlRounder(ttl)))
q.Add("record-type", "TXT") q.Set("record-type", "TXT")
reqURL.RawQuery = q.Encode() reqURL.RawQuery = q.Encode()
raw, err := c.doRequest(http.MethodPost, &reqURL) raw, err := c.doRequest(http.MethodPost, reqURL)
if err != nil { if err != nil {
return err return err
} }
resp := apiResponse{} resp := apiResponse{}
if err = json.Unmarshal(raw, &resp); err != nil { 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" { 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 return nil
} }
// RemoveTxtRecord remove a TXT record. // RemoveTxtRecord removes a TXT record.
func (c *Client) RemoveTxtRecord(recordID int, zoneName string) error { func (c *Client) RemoveTxtRecord(recordID int, zoneName string) error {
reqURL := *c.BaseURL reqURL, err := c.BaseURL.Parse(path.Join(c.BaseURL.Path, "delete-record.json"))
reqURL.Path += "delete-record.json" if err != nil {
return fmt.Errorf("failed to parse endpoint: %w", err)
}
q := reqURL.Query() q := reqURL.Query()
q.Add("domain-name", zoneName) q.Set("domain-name", zoneName)
q.Add("record-id", strconv.Itoa(recordID)) q.Set("record-id", strconv.Itoa(recordID))
reqURL.RawQuery = q.Encode() reqURL.RawQuery = q.Encode()
raw, err := c.doRequest(http.MethodPost, &reqURL) raw, err := c.doRequest(http.MethodPost, reqURL)
if err != nil { if err != nil {
return err return err
} }
resp := apiResponse{} resp := apiResponse{}
if err = json.Unmarshal(raw, &resp); err != nil { 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" { 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 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) { func (c *Client) doRequest(method string, url *url.URL) (json.RawMessage, error) {
req, err := c.buildRequest(method, url) req, err := c.buildRequest(method, url)
if err != nil { if err != nil {
@ -224,8 +284,9 @@ func (c *Client) doRequest(method string, url *url.URL) (json.RawMessage, error)
} }
if resp.StatusCode != 200 { 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 return content, nil
} }
@ -233,12 +294,12 @@ func (c *Client) buildRequest(method string, url *url.URL) (*http.Request, error
q := url.Query() q := url.Query()
if c.subAuthID != "" { if c.subAuthID != "" {
q.Add("sub-auth-id", c.subAuthID) q.Set("sub-auth-id", c.subAuthID)
} else { } 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() url.RawQuery = q.Encode()
@ -269,7 +330,6 @@ func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string {
// - 604800 = 1 week // - 604800 = 1 week
// - 1209600 = 2 weeks // - 1209600 = 2 weeks
// - 2592000 = 1 month // - 2592000 = 1 month
// - 2592000 = 1 month
// See https://www.cloudns.net/wiki/article/58/ for details. // See https://www.cloudns.net/wiki/article/58/ for details.
func ttlRounder(ttl int) int { func ttlRounder(ttl int) int {
for _, validTTL := range []int{60, 300, 900, 1800, 3600, 21600, 43200, 86400, 172800, 259200, 604800, 1209600} { for _, validTTL := range []int{60, 300, 900, 1800, 3600, 21600, 43200, 86400, 172800, 259200, 604800, 1209600} {

View file

@ -11,8 +11,8 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func handlerMock(method string, jsonData []byte) http.Handler { func handlerMock(method string, jsonData []byte) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { return func(rw http.ResponseWriter, req *http.Request) {
if req.Method != method { if req.Method != method {
http.Error(rw, "Incorrect method used", http.StatusBadRequest) http.Error(rw, "Incorrect method used", http.StatusBadRequest)
return return
@ -23,25 +23,71 @@ func handlerMock(method string, jsonData []byte) http.Handler {
http.Error(rw, err.Error(), http.StatusInternalServerError) http.Error(rw, err.Error(), http.StatusInternalServerError)
return return
} }
}) }
} }
func TestClientGetZone(t *testing.T) { func TestNewClient(t *testing.T) {
type result struct { testCases := []struct {
zone *Zone desc string
error bool 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 { testCases := []struct {
desc string desc string
authFQDN string authFQDN string
apiResponse []byte apiResponse string
expected result expected
}{ }{
{ {
desc: "zone found", desc: "zone found",
authFQDN: "_acme-challenge.foo.com.", authFQDN: "_acme-challenge.foo.com.",
apiResponse: []byte(`{"name": "foo.com", "type": "master", "zone": "zone", "status": "1"}`), apiResponse: `{"name": "foo.com", "type": "master", "zone": "zone", "status": "1"}`,
expected: result{ expected: expected{
zone: &Zone{ zone: &Zone{
Name: "foo.com", Name: "foo.com",
Type: "master", Type: "master",
@ -53,23 +99,35 @@ func TestClientGetZone(t *testing.T) {
{ {
desc: "zone not found", desc: "zone not found",
authFQDN: "_acme-challenge.foo.com.", authFQDN: "_acme-challenge.foo.com.",
apiResponse: []byte(``), apiResponse: ``,
expected: result{error: true}, 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 { for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) { 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") client, err := NewClient("myAuthID", "", "myAuthPassword")
mockBaseURL, _ := url.Parse(fmt.Sprintf("%s/", server.URL)) require.NoError(t, err)
client.BaseURL = mockBaseURL
client.BaseURL, _ = url.Parse(server.URL)
zone, err := client.GetZone(test.authFQDN) zone, err := client.GetZone(test.authFQDN)
if test.expected.error { if test.expected.errorMsg != "" {
require.Error(t, err) require.EqualError(t, err, test.expected.errorMsg)
} else { } else {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, test.expected.zone, zone) assert.Equal(t, test.expected.zone, zone)
@ -78,24 +136,24 @@ func TestClientGetZone(t *testing.T) {
} }
} }
func TestClientFindTxtRecord(t *testing.T) { func TestClient_FindTxtRecord(t *testing.T) {
type result struct { type expected struct {
txtRecord *TXTRecord txtRecord *TXTRecord
error bool errorMsg string
} }
testCases := []struct { testCases := []struct {
desc string desc string
authFQDN string authFQDN string
zoneName string zoneName string
apiResponse []byte apiResponse string
expected result expected
}{ }{
{ {
desc: "record found", desc: "record found",
authFQDN: "_acme-challenge.foo.com.", authFQDN: "_acme-challenge.foo.com.",
zoneName: "foo.com", zoneName: "foo.com",
apiResponse: []byte(`{ apiResponse: `{
"5769228": { "5769228": {
"id": "5769228", "id": "5769228",
"type": "TXT", "type": "TXT",
@ -114,8 +172,8 @@ func TestClientFindTxtRecord(t *testing.T) {
"ttl": "300", "ttl": "300",
"status": 1 "status": 1
} }
}`), }`,
expected: result{ expected: expected{
txtRecord: &TXTRecord{ txtRecord: &TXTRecord{
ID: 5769228, ID: 5769228,
Type: "TXT", 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.", authFQDN: "_acme-challenge.foo.com.",
zoneName: "test-zone", zoneName: "test-zone",
apiResponse: []byte(`[]`), apiResponse: `[]`,
expected: result{txtRecord: nil}, },
{
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 { for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) { 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") client, err := NewClient("myAuthID", "", "myAuthPassword")
require.NoError(t, err) require.NoError(t, err)
mockBaseURL, _ := url.Parse(fmt.Sprintf("%s/", server.URL)) client.BaseURL, _ = url.Parse(server.URL)
client.BaseURL = mockBaseURL
txtRecord, err := client.FindTxtRecord(test.zoneName, test.authFQDN) txtRecord, err := client.FindTxtRecord(test.zoneName, test.authFQDN)
if test.expected.error { if test.expected.errorMsg != "" {
require.Error(t, err) require.EqualError(t, err, test.expected.errorMsg)
} else { } else {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, test.expected.txtRecord, txtRecord) 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 { type expected struct {
Query string txtRecords []TXTRecord
Error string 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 { testCases := []struct {
desc string desc string
authID string authID string
subAuthID string subAuthID string
zone *Zone zoneName string
authFQDN string authFQDN string
value string value string
ttl int ttl int
apiResponse []byte apiResponse string
expected expected expected
}{ }{
{ {
desc: "sub-zone", desc: "sub-zone",
authID: "myAuthID", authID: "myAuthID",
zone: &Zone{ zoneName: "bar.com",
Name: "bar.com",
Type: "master",
Zone: "domain",
Status: "1",
},
authFQDN: "_acme-challenge.foo.bar.com.", authFQDN: "_acme-challenge.foo.bar.com.",
value: "txtTXTtxtTXTtxtTXTtxtTXT", value: "txtTXTtxtTXTtxtTXTtxtTXT",
ttl: 60, ttl: 60,
apiResponse: []byte(`{"status":"Success","statusDescription":"The record was added successfully."}`), apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`,
expected: expected{ 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)", desc: "main zone (authID)",
authID: "myAuthID", authID: "myAuthID",
zone: &Zone{ zoneName: "bar.com",
Name: "bar.com",
Type: "master",
Zone: "domain",
Status: "1",
},
authFQDN: "_acme-challenge.bar.com.", authFQDN: "_acme-challenge.bar.com.",
value: "TXTtxtTXTtxtTXTtxtTXTtxt", value: "TXTtxtTXTtxtTXTtxtTXTtxt",
ttl: 60, ttl: 60,
apiResponse: []byte(`{"status":"Success","statusDescription":"The record was added successfully."}`), apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`,
expected: expected{ 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)", desc: "main zone (subAuthID)",
authID: "myAuthID", subAuthID: "mySubAuthID",
subAuthID: "mySubAuthID", zoneName: "bar.com",
zone: &Zone{
Name: "bar.com",
Type: "master",
Zone: "domain",
Status: "1",
},
authFQDN: "_acme-challenge.bar.com.", authFQDN: "_acme-challenge.bar.com.",
value: "TXTtxtTXTtxtTXTtxtTXTtxt", value: "TXTtxtTXTtxtTXTtxtTXTtxt",
ttl: 60, ttl: 60,
apiResponse: []byte(`{"status":"Success","statusDescription":"The record was added successfully."}`), apiResponse: `{"status":"Success","statusDescription":"The record was added successfully."}`,
expected: expected{ 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", desc: "invalid status",
authID: "myAuthID", authID: "myAuthID",
zone: &Zone{ zoneName: "bar.com",
Name: "bar.com",
Type: "master",
Zone: "domain",
Status: "1",
},
authFQDN: "_acme-challenge.bar.com.", authFQDN: "_acme-challenge.bar.com.",
value: "TXTtxtTXTtxtTXTtxtTXTtxt", value: "TXTtxtTXTtxtTXTtxtTXTtxt",
ttl: 120, 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{ expected: expected{
Query: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=300`, 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.", 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 { for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) { t.Run(test.desc, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
assert.NotNil(t, req.URL.RawQuery) if test.expected.query != req.URL.RawQuery {
assert.Equal(t, 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") client, err := NewClient(test.authID, test.subAuthID, "myAuthPassword")
require.NoError(t, err) require.NoError(t, err)
mockBaseURL, _ := url.Parse(fmt.Sprintf("%s/", server.URL)) client.BaseURL, _ = url.Parse(server.URL)
client.BaseURL = mockBaseURL
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 != "" { if test.expected.errorMsg != "" {
require.EqualError(t, err, test.expected.Error) require.EqualError(t, err, test.expected.errorMsg)
} else { } else {
require.NoError(t, err) 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)
})
}
}

View file

@ -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
}