diff --git a/providers/dns/godaddy/godaddy.go b/providers/dns/godaddy/godaddy.go index a0b91d38..a3befd13 100644 --- a/providers/dns/godaddy/godaddy.go +++ b/providers/dns/godaddy/godaddy.go @@ -10,13 +10,10 @@ import ( "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/godaddy/internal" ) -const ( - // defaultBaseURL represents the API endpoint to call. - defaultBaseURL = "https://api.godaddy.com" - minTTL = 600 -) +const minTTL = 600 // Environment variables names. const ( @@ -56,6 +53,7 @@ func NewDefaultConfig() *Config { // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config + client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for godaddy. @@ -88,7 +86,13 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { return nil, fmt.Errorf("godaddy: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) } - return &DNSProvider{config: config}, nil + client := internal.NewClient(config.APIKey, config.APISecret) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{config: config, client: client}, nil } // Timeout returns the timeout and interval to use when checking for DNS @@ -108,19 +112,19 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { recordName := extractRecordName(fqdn, domainZone) - records, err := d.getRecords(domainZone, "TXT", recordName) + records, err := d.client.GetRecords(domainZone, "TXT", recordName) if err != nil { return fmt.Errorf("godaddy: failed to get TXT records: %w", err) } - var newRecords []DNSRecord + var newRecords []internal.DNSRecord for _, record := range records { if record.Data != "" { newRecords = append(newRecords, record) } } - record := DNSRecord{ + record := internal.DNSRecord{ Type: "TXT", Name: recordName, Data: value, @@ -128,7 +132,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { } newRecords = append(newRecords, record) - err = d.updateTxtRecords(newRecords, domainZone, recordName) + err = d.client.UpdateTxtRecords(newRecords, domainZone, recordName) if err != nil { return fmt.Errorf("godaddy: failed to add TXT record: %w", err) } @@ -147,7 +151,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { recordName := extractRecordName(fqdn, domainZone) - records, err := d.getRecords(domainZone, "TXT", recordName) + records, err := d.client.GetRecords(domainZone, "TXT", recordName) if err != nil { return fmt.Errorf("godaddy: failed to get TXT records: %w", err) } @@ -156,12 +160,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } - allTxtRecords, err := d.getRecords(domainZone, "TXT", "") + allTxtRecords, err := d.client.GetRecords(domainZone, "TXT", "") if err != nil { return fmt.Errorf("godaddy: failed to get all TXT records: %w", err) } - var recordsKeep []DNSRecord + var recordsKeep []internal.DNSRecord for _, record := range allTxtRecords { if record.Data != value && record.Data != "" { recordsKeep = append(recordsKeep, record) @@ -170,11 +174,11 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // GoDaddy API don't provide a way to delete a record, an "empty" record must be added. if len(recordsKeep) == 0 { - emptyRecord := DNSRecord{Name: "empty", Data: ""} + emptyRecord := internal.DNSRecord{Name: "empty", Data: ""} recordsKeep = append(recordsKeep, emptyRecord) } - err = d.updateTxtRecords(recordsKeep, domainZone, "") + err = d.client.UpdateTxtRecords(recordsKeep, domainZone, "") if err != nil { return fmt.Errorf("godaddy: failed to remove TXT record: %w", err) } diff --git a/providers/dns/godaddy/client.go b/providers/dns/godaddy/internal/client.go similarity index 59% rename from providers/dns/godaddy/client.go rename to providers/dns/godaddy/internal/client.go index d78d7890..c1096ce5 100644 --- a/providers/dns/godaddy/client.go +++ b/providers/dns/godaddy/internal/client.go @@ -1,4 +1,4 @@ -package godaddy +package internal import ( "bytes" @@ -6,19 +6,33 @@ import ( "fmt" "io" "net/http" + "net/url" "path" + "time" ) -// DNSRecord a DNS record. -type DNSRecord struct { - Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` - Data string `json:"data"` - Priority int `json:"priority,omitempty"` - TTL int `json:"ttl,omitempty"` +// DefaultBaseURL represents the API endpoint to call. +const DefaultBaseURL = "https://api.godaddy.com" + +type Client struct { + HTTPClient *http.Client + baseURL *url.URL + apiKey string + apiSecret string } -func (d *DNSProvider) getRecords(domainZone, rType, recordName string) ([]DNSRecord, error) { +func NewClient(apiKey string, apiSecret string) *Client { + baseURL, _ := url.Parse(DefaultBaseURL) + + return &Client{ + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + baseURL: baseURL, + apiKey: apiKey, + apiSecret: apiSecret, + } +} + +func (d *Client) GetRecords(domainZone, rType, recordName string) ([]DNSRecord, error) { resource := path.Clean(fmt.Sprintf("/v1/domains/%s/records/%s/%s", domainZone, rType, recordName)) resp, err := d.makeRequest(http.MethodGet, resource, nil) @@ -43,7 +57,7 @@ func (d *DNSProvider) getRecords(domainZone, rType, recordName string) ([]DNSRec return records, nil } -func (d *DNSProvider) updateTxtRecords(records []DNSRecord, domainZone, recordName string) error { +func (d *Client) UpdateTxtRecords(records []DNSRecord, domainZone, recordName string) error { body, err := json.Marshal(records) if err != nil { return err @@ -67,15 +81,20 @@ func (d *DNSProvider) updateTxtRecords(records []DNSRecord, domainZone, recordNa return nil } -func (d *DNSProvider) makeRequest(method, uri string, body io.Reader) (*http.Response, error) { - req, err := http.NewRequest(method, fmt.Sprintf("%s%s", defaultBaseURL, uri), body) +func (d *Client) makeRequest(method, uri string, body io.Reader) (*http.Response, error) { + endpoint, err := d.baseURL.Parse(path.Join(d.baseURL.Path, uri)) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(method, endpoint.String(), body) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("sso-key %s:%s", d.config.APIKey, d.config.APISecret)) + req.Header.Set("Authorization", fmt.Sprintf("sso-key %s:%s", d.apiKey, d.apiSecret)) - return d.config.HTTPClient.Do(req) + return d.HTTPClient.Do(req) } diff --git a/providers/dns/godaddy/internal/client_test.go b/providers/dns/godaddy/internal/client_test.go new file mode 100644 index 00000000..5e297453 --- /dev/null +++ b/providers/dns/godaddy/internal/client_test.go @@ -0,0 +1,142 @@ +package internal + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTest(t *testing.T) (*http.ServeMux, *Client) { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + client := NewClient("key", "secret") + client.HTTPClient = server.Client() + client.baseURL, _ = url.Parse(server.URL) + + return mux, client +} + +func TestClient_GetRecords(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/v1/domains/example.com/records/TXT/", testHandler(http.MethodGet, http.StatusOK, "getrecords.json")) + + records, err := client.GetRecords("example.com", "TXT", "") + require.NoError(t, err) + + expected := []DNSRecord{ + {Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600}, + {Name: "_acme-challenge.example", Type: "TXT", Data: "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", TTL: 600}, + {Name: "_acme-challenge.example", Type: "TXT", Data: "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", TTL: 600}, + {Name: "_acme-challenge.lego", Type: "TXT", Data: " ", TTL: 600}, + {Name: "_acme-challenge.lego", Type: "TXT", Data: "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", TTL: 600}, + {Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600}, + } + + assert.Equal(t, expected, records) +} + +func TestClient_GetRecords_errors(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/v1/domains/example.com/records/TXT/", testHandler(http.MethodGet, http.StatusUnprocessableEntity, "errors.json")) + + records, err := client.GetRecords("example.com", "TXT", "") + require.Error(t, err) + assert.Nil(t, records) +} + +func TestClient_UpdateTxtRecords(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/v1/domains/example.com/records/TXT/lego", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPut { + http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) + return + } + + auth := req.Header.Get("Authorization") + if auth != "sso-key key:secret" { + http.Error(rw, fmt.Sprintf("invalid API key or secret: %s", auth), http.StatusUnauthorized) + return + } + }) + + records := []DNSRecord{ + {Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600}, + {Name: "_acme-challenge.example", Type: "TXT", Data: "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", TTL: 600}, + {Name: "_acme-challenge.example", Type: "TXT", Data: "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", TTL: 600}, + {Name: "_acme-challenge.lego", Type: "TXT", Data: " ", TTL: 600}, + {Name: "_acme-challenge.lego", Type: "TXT", Data: "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", TTL: 600}, + {Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600}, + } + + err := client.UpdateTxtRecords(records, "example.com", "lego") + require.NoError(t, err) +} + +func TestClient_UpdateTxtRecords_errors(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/v1/domains/example.com/records/TXT/lego", + testHandler(http.MethodPut, http.StatusUnprocessableEntity, "errors.json")) + + records := []DNSRecord{ + {Name: "_acme-challenge", Type: "TXT", Data: " ", TTL: 600}, + {Name: "_acme-challenge.example", Type: "TXT", Data: "6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", TTL: 600}, + {Name: "_acme-challenge.example", Type: "TXT", Data: "8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", TTL: 600}, + {Name: "_acme-challenge.lego", Type: "TXT", Data: " ", TTL: 600}, + {Name: "_acme-challenge.lego", Type: "TXT", Data: "0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", TTL: 600}, + {Name: "_acme-challenge.lego", Type: "TXT", Data: "acme", TTL: 600}, + } + + err := client.UpdateTxtRecords(records, "example.com", "lego") + require.Error(t, err) +} + +func testHandler(method string, statusCode int, filename string) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf(`{"message":"unsupported method: %s"}`, req.Method), http.StatusMethodNotAllowed) + return + } + + auth := req.Header.Get("Authorization") + if auth != "sso-key key:secret" { + http.Error(rw, fmt.Sprintf("invalid API key or secret: %s", auth), http.StatusUnauthorized) + return + } + + rw.WriteHeader(statusCode) + + if statusCode == http.StatusNoContent { + return + } + + file, err := os.Open(filepath.Join("fixtures", filename)) + if err != nil { + http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) + return + } + + defer func() { _ = file.Close() }() + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, fmt.Sprintf(`{"message":"%v"}`, err), http.StatusInternalServerError) + return + } + } +} diff --git a/providers/dns/godaddy/internal/fixtures/errors.json b/providers/dns/godaddy/internal/fixtures/errors.json new file mode 100644 index 00000000..b9749475 --- /dev/null +++ b/providers/dns/godaddy/internal/fixtures/errors.json @@ -0,0 +1,4 @@ +{ + "code": "INVALID_BODY", + "message": "Request body doesn't fulfill schema, see details in `fields`" +} diff --git a/providers/dns/godaddy/internal/fixtures/getrecords.json b/providers/dns/godaddy/internal/fixtures/getrecords.json new file mode 100644 index 00000000..ca915f36 --- /dev/null +++ b/providers/dns/godaddy/internal/fixtures/getrecords.json @@ -0,0 +1,38 @@ +[ + { + "name":"_acme-challenge", + "type":"TXT", + "data":" ", + "ttl":600 + }, + { + "name":"_acme-challenge.example", + "type":"TXT", + "data":"6rrai7-jm7l3PxL4WGmGoS6VMeefSHx24r-qCvUIOxU", + "ttl":600 + }, + { + "name":"_acme-challenge.example", + "type":"TXT", + "data":"8Axt-PXQvjOVD2oi2YXqyyn8U5CDcC8P-BphlCxk3Ek", + "ttl":600 + }, + { + "name":"_acme-challenge.lego", + "type":"TXT", + "data":" ", + "ttl":600 + }, + { + "name":"_acme-challenge.lego", + "type":"TXT", + "data":"0Ad60wO_yxxJPFPb1deir6lQ37FPLeA02YCobo7Qm8A", + "ttl":600 + }, + { + "name":"_acme-challenge.lego", + "type":"TXT", + "data":"acme", + "ttl":600 + } +] diff --git a/providers/dns/godaddy/internal/types.go b/providers/dns/godaddy/internal/types.go new file mode 100644 index 00000000..fc06cda0 --- /dev/null +++ b/providers/dns/godaddy/internal/types.go @@ -0,0 +1,15 @@ +package internal + +// DNSRecord a DNS record. +type DNSRecord struct { + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Data string `json:"data"` + TTL int `json:"ttl,omitempty"` + + Priority int `json:"priority,omitempty"` + Port int `json:"port,omitempty"` + Protocol string `json:"protocol,omitempty"` + Service string `json:"service,omitempty"` + Weight int `json:"weight,omitempty"` +}