diff --git a/providers/dns/cloudxns/cloudxns.go b/providers/dns/cloudxns/cloudxns.go index 6269b8da7e..422e2c8428 100644 --- a/providers/dns/cloudxns/cloudxns.go +++ b/providers/dns/cloudxns/cloudxns.go @@ -2,28 +2,11 @@ package cloudxns import ( - "context" "errors" - "fmt" "net/http" "time" "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/cloudxns/internal" -) - -// Environment variables names. -const ( - envNamespace = "CLOUDXNS_" - - EnvAPIKey = envNamespace + "API_KEY" - EnvSecretKey = envNamespace + "SECRET_KEY" - - EnvTTL = envNamespace + "TTL" - EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" - EnvPollingInterval = envNamespace + "POLLING_INTERVAL" - EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // Config is used to configure the creation of the DNSProvider. @@ -38,101 +21,34 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { - return &Config{ - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), - TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), - HTTPClient: &http.Client{ - Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), - }, - } + return &Config{} } // DNSProvider implements the challenge.Provider interface. -type DNSProvider struct { - config *Config - client *internal.Client -} +type DNSProvider struct{} // 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(EnvAPIKey, EnvSecretKey) - if err != nil { - return nil, fmt.Errorf("cloudxns: %w", err) - } - - config := NewDefaultConfig() - config.APIKey = values[EnvAPIKey] - config.SecretKey = values[EnvSecretKey] - - return NewDNSProviderConfig(config) + 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") - } - - client, err := internal.NewClient(config.APIKey, config.SecretKey) - if err != nil { - return nil, fmt.Errorf("cloudxns: %w", err) - } - - if config.HTTPClient != nil { - client.HTTPClient = config.HTTPClient - } - - return &DNSProvider{client: client, config: config}, nil +func NewDNSProviderConfig(_ *Config) (*DNSProvider, error) { + return nil, errors.New("cloudxns: provider has shut down") } // Present creates a TXT record to fulfill the dns-01 challenge. -func (d *DNSProvider) Present(domain, token, keyAuth string) error { - challengeInfo := dns01.GetChallengeInfo(domain, keyAuth) - - ctx := context.Background() - - info, err := d.client.GetDomainInformation(ctx, challengeInfo.EffectiveFQDN) - if err != nil { - return fmt.Errorf("cloudxns: %w", err) - } - - err = d.client.AddTxtRecord(ctx, info, challengeInfo.EffectiveFQDN, challengeInfo.Value, d.config.TTL) - if err != nil { - return fmt.Errorf("cloudxns: %w", err) - } - +func (d *DNSProvider) Present(_, _, _ string) error { return nil } // CleanUp removes the TXT record matching the specified parameters. -func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - challengeInfo := dns01.GetChallengeInfo(domain, keyAuth) - - ctx := context.Background() - - info, err := d.client.GetDomainInformation(ctx, challengeInfo.EffectiveFQDN) - if err != nil { - return fmt.Errorf("cloudxns: %w", err) - } - - record, err := d.client.FindTxtRecord(ctx, info.ID, challengeInfo.EffectiveFQDN) - if err != nil { - return fmt.Errorf("cloudxns: %w", err) - } - - err = d.client.RemoveTxtRecord(ctx, record.RecordID, info.ID) - if err != nil { - return fmt.Errorf("cloudxns: %w", err) - } - +func (d *DNSProvider) CleanUp(_, _, _ string) error { return nil } // 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 + return dns01.DefaultPropagationTimeout, dns01.DefaultPollingInterval } diff --git a/providers/dns/cloudxns/cloudxns_test.go b/providers/dns/cloudxns/cloudxns_test.go deleted file mode 100644 index 0b3271761e..0000000000 --- a/providers/dns/cloudxns/cloudxns_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package cloudxns - -import ( - "testing" - "time" - - "github.com/go-acme/lego/v4/platform/tester" - "github.com/stretchr/testify/require" -) - -const envDomain = envNamespace + "DOMAIN" - -var envTest = tester.NewEnvTest( - EnvAPIKey, - EnvSecretKey). - WithDomain(envDomain) - -func TestNewDNSProvider(t *testing.T) { - testCases := []struct { - desc string - envVars map[string]string - expected string - }{ - { - desc: "success", - envVars: map[string]string{ - EnvAPIKey: "123", - EnvSecretKey: "456", - }, - }, - { - desc: "missing credentials", - envVars: map[string]string{ - EnvAPIKey: "", - EnvSecretKey: "", - }, - expected: "cloudxns: some credentials information are missing: CLOUDXNS_API_KEY,CLOUDXNS_SECRET_KEY", - }, - { - desc: "missing API key", - envVars: map[string]string{ - EnvAPIKey: "", - EnvSecretKey: "456", - }, - expected: "cloudxns: some credentials information are missing: CLOUDXNS_API_KEY", - }, - { - desc: "missing secret key", - envVars: map[string]string{ - EnvAPIKey: "123", - EnvSecretKey: "", - }, - expected: "cloudxns: some credentials information are missing: CLOUDXNS_SECRET_KEY", - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - defer envTest.RestoreEnv() - envTest.ClearEnv() - - envTest.Apply(test.envVars) - - p, err := NewDNSProvider() - - if test.expected == "" { - require.NoError(t, err) - require.NotNil(t, p) - require.NotNil(t, p.config) - require.NotNil(t, p.client) - } else { - require.EqualError(t, err, test.expected) - } - }) - } -} - -func TestNewDNSProviderConfig(t *testing.T) { - testCases := []struct { - desc string - apiKey string - secretKey string - expected string - }{ - { - desc: "success", - apiKey: "123", - secretKey: "456", - }, - { - desc: "missing credentials", - expected: "cloudxns: credentials missing: apiKey", - }, - { - desc: "missing api key", - secretKey: "456", - expected: "cloudxns: credentials missing: apiKey", - }, - { - desc: "missing secret key", - apiKey: "123", - expected: "cloudxns: credentials missing: secretKey", - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - config := NewDefaultConfig() - config.APIKey = test.apiKey - config.SecretKey = test.secretKey - - p, err := NewDNSProviderConfig(config) - - if test.expected == "" { - require.NoError(t, err) - require.NotNil(t, p) - require.NotNil(t, p.config) - require.NotNil(t, p.client) - } else { - require.EqualError(t, err, test.expected) - } - }) - } -} - -func TestLivePresent(t *testing.T) { - if !envTest.IsLiveTest() { - t.Skip("skipping live test") - } - - envTest.RestoreEnv() - provider, err := NewDNSProvider() - require.NoError(t, err) - - err = provider.Present(envTest.GetDomain(), "", "123d==") - require.NoError(t, err) -} - -func TestLiveCleanUp(t *testing.T) { - if !envTest.IsLiveTest() { - t.Skip("skipping live test") - } - - envTest.RestoreEnv() - provider, err := NewDNSProvider() - require.NoError(t, err) - - time.Sleep(2 * time.Second) - - err = provider.CleanUp(envTest.GetDomain(), "", "123d==") - require.NoError(t, err) -} diff --git a/providers/dns/cloudxns/internal/client.go b/providers/dns/cloudxns/internal/client.go deleted file mode 100644 index 37f10fe872..0000000000 --- a/providers/dns/cloudxns/internal/client.go +++ /dev/null @@ -1,221 +0,0 @@ -package internal - -import ( - "bytes" - "context" - "crypto/md5" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "time" - - "github.com/go-acme/lego/v4/challenge/dns01" - "github.com/go-acme/lego/v4/providers/dns/internal/errutils" -) - -const defaultBaseURL = "https://www.cloudxns.net/api2/" - -// Client CloudXNS client. -type Client struct { - apiKey string - secretKey string - - baseURL *url.URL - HTTPClient *http.Client -} - -// NewClient creates a CloudXNS client. -func NewClient(apiKey, secretKey string) (*Client, error) { - if apiKey == "" { - return nil, errors.New("credentials missing: apiKey") - } - - if secretKey == "" { - return nil, errors.New("credentials missing: secretKey") - } - - baseURL, _ := url.Parse(defaultBaseURL) - - return &Client{ - apiKey: apiKey, - secretKey: secretKey, - baseURL: baseURL, - HTTPClient: &http.Client{Timeout: 10 * time.Second}, - }, nil -} - -// GetDomainInformation Get domain name information for a FQDN. -func (c *Client) GetDomainInformation(ctx context.Context, fqdn string) (*Data, error) { - endpoint := c.baseURL.JoinPath("domain") - - req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, err - } - - authZone, err := dns01.FindZoneByFqdn(fqdn) - if err != nil { - return nil, fmt.Errorf("could not find zone: %w", err) - } - - var domains []Data - err = c.do(req, &domains) - if err != nil { - return nil, err - } - - for _, data := range domains { - if data.Domain == authZone { - return &data, nil - } - } - - return nil, fmt.Errorf("zone %s not found for domain %s", authZone, fqdn) -} - -// FindTxtRecord return the TXT record a zone ID and a FQDN. -func (c *Client) FindTxtRecord(ctx context.Context, zoneID, fqdn string) (*TXTRecord, error) { - endpoint := c.baseURL.JoinPath("record", zoneID) - - query := endpoint.Query() - query.Set("host_id", "0") - query.Set("offset", "0") - query.Set("row_num", "2000") - endpoint.RawQuery = query.Encode() - - req, err := c.newRequest(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, err - } - - var records []TXTRecord - err = c.do(req, &records) - if err != nil { - return nil, err - } - - for _, record := range records { - if record.Host == dns01.UnFqdn(fqdn) && record.Type == "TXT" { - return &record, nil - } - } - - return nil, fmt.Errorf("no existing record found for %q", fqdn) -} - -// AddTxtRecord add a TXT record. -func (c *Client) AddTxtRecord(ctx context.Context, info *Data, fqdn, value string, ttl int) error { - id, err := strconv.Atoi(info.ID) - if err != nil { - return fmt.Errorf("invalid zone ID: %w", err) - } - - endpoint := c.baseURL.JoinPath("record") - - subDomain, err := dns01.ExtractSubDomain(fqdn, info.Domain) - if err != nil { - return err - } - - record := TXTRecord{ - ID: id, - Host: subDomain, - Value: value, - Type: "TXT", - LineID: 1, - TTL: ttl, - } - - req, err := c.newRequest(ctx, http.MethodPost, endpoint, record) - if err != nil { - return err - } - - return c.do(req, nil) -} - -// RemoveTxtRecord remove a TXT record. -func (c *Client) RemoveTxtRecord(ctx context.Context, recordID, zoneID string) error { - endpoint := c.baseURL.JoinPath("record", recordID, zoneID) - - req, err := c.newRequest(ctx, http.MethodDelete, endpoint, nil) - if err != nil { - return err - } - - return c.do(req, nil) -} - -func (c *Client) do(req *http.Request, result any) error { - resp, err := c.HTTPClient.Do(req) - if err != nil { - return errutils.NewHTTPDoError(req, err) - } - - defer func() { _ = resp.Body.Close() }() - - raw, err := io.ReadAll(resp.Body) - if err != nil { - return errutils.NewReadResponseError(req, resp.StatusCode, err) - } - - var response apiResponse - err = json.Unmarshal(raw, &response) - if err != nil { - return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) - } - - if response.Code != 1 { - return fmt.Errorf("[status code %d] invalid code (%v) error: %s", resp.StatusCode, response.Code, response.Message) - } - - if result == nil { - return nil - } - - if len(response.Data) == 0 { - return nil - } - - err = json.Unmarshal(response.Data, result) - if err != nil { - return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) - } - - return nil -} - -func (c *Client) newRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { - buf := new(bytes.Buffer) - - if payload != nil { - err := json.NewEncoder(buf).Encode(payload) - if err != nil { - return nil, fmt.Errorf("failed to create request JSON body: %w", err) - } - } - - req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) - if err != nil { - return nil, fmt.Errorf("unable to create request: %w", 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(endpoint.String(), requestDate, buf.String())) - req.Header.Set("API-FORMAT", "json") - - return req, nil -} - -func (c *Client) hmac(endpoint, date, body string) string { - sum := md5.Sum([]byte(c.apiKey + endpoint + body + date + c.secretKey)) - return hex.EncodeToString(sum[:]) -} diff --git a/providers/dns/cloudxns/internal/client_test.go b/providers/dns/cloudxns/internal/client_test.go deleted file mode 100644 index ac4e36d6b7..0000000000 --- a/providers/dns/cloudxns/internal/client_test.go +++ /dev/null @@ -1,292 +0,0 @@ -package internal - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func setupTest(t *testing.T, handler http.HandlerFunc) *Client { - t.Helper() - - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - - client, _ := NewClient("myKey", "mySecret") - client.baseURL, _ = url.Parse(server.URL + "/") - client.HTTPClient = server.Client() - - return client -} - -func handlerMock(method string, response *apiResponse, data interface{}) http.HandlerFunc { - return 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 - } - - _, err = rw.Write(content) - if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } -} - -func TestClient_GetDomainInformation(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.example.org.", - response: &apiResponse{ - Code: 1, - }, - data: []Data{ - { - ID: "1", - Domain: "example.com.", - }, - { - ID: "2", - Domain: "example.org.", - }, - }, - expected: result{domain: &Data{ - ID: "2", - Domain: "example.org.", - }}, - }, - { - desc: "domains not found", - fqdn: "_acme-challenge.huu.com.", - response: &apiResponse{ - Code: 1, - }, - data: []Data{ - { - ID: "5", - Domain: "example.com.", - }, - { - ID: "6", - Domain: "example.org.", - }, - }, - expected: result{error: true}, - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - client := setupTest(t, handlerMock(http.MethodGet, test.response, test.data)) - - domain, err := client.GetDomainInformation(context.Background(), test.fqdn) - - if test.expected.error { - require.Error(t, err) - } else { - require.NoError(t, err) - assert.Equal(t, test.expected.domain, domain) - } - }) - } -} - -func TestClient_FindTxtRecord(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.example.org.", - zoneID: "test-zone", - txtRecords: []TXTRecord{ - { - ID: 1, - RecordID: "Record-A", - Host: "_acme-challenge.example.org", - Value: "txtTXTtxtTXTtxtTXTtxtTXT", - Type: "TXT", - LineID: 6, - TTL: 30, - }, - { - ID: 2, - RecordID: "Record-B", - Host: "_acme-challenge.example.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.example.org", - 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.example.org", - Value: "txtTXTtxtTXTtxtTXTtxtTXT", - Type: "TXT", - LineID: 6, - TTL: 30, - }, - { - ID: 2, - RecordID: "Record-B", - Host: "_acme-challenge.example.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) { - client := setupTest(t, handlerMock(http.MethodGet, test.response, test.txtRecords)) - - txtRecord, err := client.FindTxtRecord(context.Background(), 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 TestClient_AddTxtRecord(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: "example.com.", - }, - fqdn: "_acme-challenge.foo.example.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: "example.com.", - }, - fqdn: "_acme-challenge.example.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, - } - - client := setupTest(t, func(rw http.ResponseWriter, req *http.Request) { - assert.NotNil(t, req.Body) - content, err := io.ReadAll(req.Body) - require.NoError(t, err) - - assert.Equal(t, test.expected, string(bytes.TrimSpace(content))) - - handlerMock(http.MethodPost, response, nil).ServeHTTP(rw, req) - }) - - err := client.AddTxtRecord(context.Background(), test.domain, test.fqdn, test.value, test.ttl) - require.NoError(t, err) - }) - } -} diff --git a/providers/dns/cloudxns/internal/types.go b/providers/dns/cloudxns/internal/types.go deleted file mode 100644 index c1b24e30c2..0000000000 --- a/providers/dns/cloudxns/internal/types.go +++ /dev/null @@ -1,28 +0,0 @@ -package internal - -import "encoding/json" - -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"` -}