From 106a90fa71d0d3eb1a1614428f4b76e4d5aab20a Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Mon, 12 Jan 2026 20:59:16 +0100 Subject: [PATCH] Add DNS provider for Leaseweb --- providers/dns/leaseweb/internal/client.go | 216 ++++++++++++++++++ .../dns/leaseweb/internal/client_test.go | 149 ++++++++++++ .../createResourceRecordSet-request.json | 8 + .../fixtures/createResourceRecordSet.json | 17 ++ .../leaseweb/internal/fixtures/error_400.json | 6 + .../leaseweb/internal/fixtures/error_401.json | 5 + .../leaseweb/internal/fixtures/error_404.json | 5 + .../fixtures/getResourceRecordSet.json | 18 ++ .../fixtures/getResourceRecordSet2.json | 17 ++ .../updateResourceRecordSet-request.json | 8 + .../updateResourceRecordSet-request2.json | 6 + .../fixtures/updateResourceRecordSet.json | 19 ++ providers/dns/leaseweb/internal/types.go | 35 +++ providers/dns/leaseweb/leaseweb.go | 187 +++++++++++++++ providers/dns/leaseweb/leaseweb.toml | 22 ++ providers/dns/leaseweb/leaseweb_test.go | 204 +++++++++++++++++ 16 files changed, 922 insertions(+) create mode 100644 providers/dns/leaseweb/internal/client.go create mode 100644 providers/dns/leaseweb/internal/client_test.go create mode 100644 providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json create mode 100644 providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json create mode 100644 providers/dns/leaseweb/internal/fixtures/error_400.json create mode 100644 providers/dns/leaseweb/internal/fixtures/error_401.json create mode 100644 providers/dns/leaseweb/internal/fixtures/error_404.json create mode 100644 providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json create mode 100644 providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json create mode 100644 providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json create mode 100644 providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json create mode 100644 providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json create mode 100644 providers/dns/leaseweb/internal/types.go create mode 100644 providers/dns/leaseweb/leaseweb.go create mode 100644 providers/dns/leaseweb/leaseweb.toml create mode 100644 providers/dns/leaseweb/leaseweb_test.go diff --git a/providers/dns/leaseweb/internal/client.go b/providers/dns/leaseweb/internal/client.go new file mode 100644 index 000000000..01619d49b --- /dev/null +++ b/providers/dns/leaseweb/internal/client.go @@ -0,0 +1,216 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" + "github.com/go-acme/lego/v4/providers/dns/internal/useragent" +) + +const defaultBaseURL = "https://api.leaseweb.com/hosting/v2" + +const AuthHeader = "X-LSW-Auth" + +// Client the Leaseweb API client. +type Client struct { + apiKey string + + BaseURL *url.URL + HTTPClient *http.Client +} + +// NewClient creates a new Client. +func NewClient(apiKey string) (*Client, error) { + if apiKey == "" { + return nil, errors.New("credentials missing") + } + + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiKey: apiKey, + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// CreateRRSet creates a resource record set. +// https://developer.leaseweb.com/docs/#tag/DNS/operation/createResourceRecordSet +func (c *Client) CreateRRSet(ctx context.Context, domainName string, rrset RRSet) (*RRSet, error) { + endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets") + + req, err := newJSONRequest(ctx, http.MethodPost, endpoint, rrset) + if err != nil { + return nil, err + } + + result := &RRSet{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// GetRRSet gets a resource record set. +// https://developer.leaseweb.com/docs/#tag/DNS/operation/getResourceRecordSet +func (c *Client) GetRRSet(ctx context.Context, domainName, name, rType string) (*RRSet, error) { + endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", name, rType) + + req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + result := &RRSet{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// UpdateRRSet updates a resource record set. +// https://developer.leaseweb.com/docs/#tag/DNS/operation/updateResourceRecordSet +func (c *Client) UpdateRRSet(ctx context.Context, domainName string, rrset RRSet) (*RRSet, error) { + endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", rrset.Name, rrset.Type) + + // Reset values that are not allowed to be updated. + rrset.Name = "" + rrset.Type = "" + rrset.Editable = false + + req, err := newJSONRequest(ctx, http.MethodPut, endpoint, rrset) + if err != nil { + return nil, err + } + + result := &RRSet{} + + err = c.do(req, result) + if err != nil { + return nil, err + } + + return result, nil +} + +// DeleteRRSet deletes a resource record set. +// https://developer.leaseweb.com/docs/#tag/DNS/operation/deleteResourceRecordSet +func (c *Client) DeleteRRSet(ctx context.Context, domainName, name, rType string) error { + endpoint := c.BaseURL.JoinPath("domains", domainName, "resourceRecordSets", name, rType) + + req, err := newJSONRequest(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 { + useragent.SetHeader(req.Header) + + req.Header.Add(AuthHeader, c.apiKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return errutils.NewHTTPDoError(req, err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + return parseError(req, resp) + } + + if result == nil { + return nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return errutils.NewReadResponseError(req, resp.StatusCode, err) + } + + err = json.Unmarshal(raw, result) + if err != nil { + return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) + } + + return nil +} + +func newJSONRequest(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) + } + + req.Header.Set("Accept", "application/json") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func parseError(req *http.Request, resp *http.Response) error { + raw, _ := io.ReadAll(resp.Body) + + var errAPI APIError + + err := json.Unmarshal(raw, &errAPI) + if err != nil { + if resp.StatusCode == http.StatusNotFound { + return &NotFoundError{APIError{ + CorrelationID: resp.Header.Get("Correlation-Id"), + ErrorCode: strconv.Itoa(http.StatusNotFound), + ErrorMessage: string(raw), + }} + } + + return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) + } + + if errAPI.ErrorCode == strconv.Itoa(http.StatusNotFound) { + return &NotFoundError{APIError: errAPI} + } + + return &errAPI +} + +// TTLRounder rounds the given TTL in seconds to the next accepted value. +// Accepted TTL values are: 60, 300, 1800, 3600, 14400, 28800, 43200, 86400. +func TTLRounder(ttl int) int { + for _, validTTL := range []int{60, 300, 1800, 3600, 14400, 28800, 43200, 86400} { + if ttl <= validTTL { + return validTTL + } + } + + return 3600 +} diff --git a/providers/dns/leaseweb/internal/client_test.go b/providers/dns/leaseweb/internal/client_test.go new file mode 100644 index 000000000..5762aad4b --- /dev/null +++ b/providers/dns/leaseweb/internal/client_test.go @@ -0,0 +1,149 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockBuilder() *servermock.Builder[*Client] { + return servermock.NewBuilder[*Client]( + func(server *httptest.Server) (*Client, error) { + client, err := NewClient("secret") + if err != nil { + return nil, err + } + + client.BaseURL, _ = url.Parse(server.URL) + client.HTTPClient = server.Client() + + return client, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With(AuthHeader, "secret"), + ) +} + +func TestClient_CreateRRSet(t *testing.T) { + client := mockBuilder(). + Route("POST /domains/example.com/resourceRecordSets", + servermock.ResponseFromFixture("createResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromFixture("createResourceRecordSet-request.json"), + ). + Build(t) + + rrset := RRSet{ + Content: []string{"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + Name: "_acme-challenge.example.com.", + TTL: 300, + Type: "TXT", + } + + result, err := client.CreateRRSet(t.Context(), "example.com", rrset) + require.NoError(t, err) + + expected := &RRSet{ + Content: []string{"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + Name: "_acme-challenge.example.com.", + Editable: true, + TTL: 300, + Type: "TXT", + } + + assert.Equal(t, expected, result) +} + +func TestClient_GetRRSet(t *testing.T) { + client := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("getResourceRecordSet.json"), + ). + Build(t) + + result, err := client.GetRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") + require.NoError(t, err) + + expected := &RRSet{ + Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo"}, + Name: "_acme-challenge.example.com.", + Editable: true, + TTL: 3600, + Type: "TXT", + } + + assert.Equal(t, expected, result) +} + +func TestClient_GetRRSet_error_404(t *testing.T) { + client := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("error_404.json"). + WithStatusCode(http.StatusNotFound), + ). + Build(t) + + _, err := client.GetRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") + require.EqualError(t, err, "404: Resource not found (289346a1-3eaf-4da4-b707-62ef12eb08be)") + + target := &NotFoundError{} + require.ErrorAs(t, err, &target) +} + +func TestClient_UpdateRRSet(t *testing.T) { + client := mockBuilder(). + Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("updateResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromFixture("updateResourceRecordSet-request.json"), + ). + Build(t) + + rrset := RRSet{ + Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + Name: "_acme-challenge.example.com.", + TTL: 3600, + Type: "TXT", + } + + result, err := client.UpdateRRSet(t.Context(), "example.com", rrset) + require.NoError(t, err) + + expected := &RRSet{ + Content: []string{"foo", "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"}, + Name: "_acme-challenge.example.com.", + Editable: true, + TTL: 3600, + Type: "TXT", + } + + assert.Equal(t, expected, result) +} + +func TestClient_DeleteRRSet(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + ). + Build(t) + + err := client.DeleteRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") + require.NoError(t, err) +} + +func TestClient_DeleteRRSet_error(t *testing.T) { + client := mockBuilder(). + Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromFixture("error_401.json"). + WithStatusCode(http.StatusUnauthorized), + ). + Build(t) + + err := client.DeleteRRSet(t.Context(), "example.com", "_acme-challenge.example.com.", "TXT") + require.EqualError(t, err, "401: You are not authorized to view this resource. (289346a1-3eaf-4da4-b707-62ef12eb08be)") +} diff --git a/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json new file mode 100644 index 000000000..af53fcf04 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet-request.json @@ -0,0 +1,8 @@ +{ + "content": [ + "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + ], + "name": "_acme-challenge.example.com.", + "ttl": 300, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json new file mode 100644 index 000000000..8ca040d63 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/createResourceRecordSet.json @@ -0,0 +1,17 @@ +{ + "_links": { + "self": { + "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" + }, + "collection": { + "href": "/domains/example.com/resourceRecordSets" + } + }, + "content": [ + "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + ], + "editable": true, + "name": "_acme-challenge.example.com.", + "ttl": 300, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/fixtures/error_400.json b/providers/dns/leaseweb/internal/fixtures/error_400.json new file mode 100644 index 000000000..1a980b6bb --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/error_400.json @@ -0,0 +1,6 @@ +{ + "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be", + "errorCode": "400", + "errorDetails": {}, + "errorMessage": "The API could not interpret your request correctly." +} diff --git a/providers/dns/leaseweb/internal/fixtures/error_401.json b/providers/dns/leaseweb/internal/fixtures/error_401.json new file mode 100644 index 000000000..47d8a311d --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/error_401.json @@ -0,0 +1,5 @@ +{ + "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be", + "errorCode": "401", + "errorMessage": "You are not authorized to view this resource." +} diff --git a/providers/dns/leaseweb/internal/fixtures/error_404.json b/providers/dns/leaseweb/internal/fixtures/error_404.json new file mode 100644 index 000000000..1deaf5606 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/error_404.json @@ -0,0 +1,5 @@ +{ + "correlationId": "289346a1-3eaf-4da4-b707-62ef12eb08be", + "errorCode": "404", + "errorMessage": "Resource not found" +} diff --git a/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json new file mode 100644 index 000000000..fd48f60c6 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet.json @@ -0,0 +1,18 @@ +{ + "_links": { + "self": { + "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" + }, + "collection": { + "href": "/domains/example.com/resourceRecordSets" + } + }, + "content": [ + "foo", + "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo" + ], + "editable": true, + "name": "_acme-challenge.example.com.", + "ttl": 3600, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json new file mode 100644 index 000000000..abf3fb4c3 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/getResourceRecordSet2.json @@ -0,0 +1,17 @@ +{ + "_links": { + "self": { + "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" + }, + "collection": { + "href": "/domains/example.com/resourceRecordSets" + } + }, + "content": [ + "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo" + ], + "editable": true, + "name": "_acme-challenge.example.com.", + "ttl": 3600, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json new file mode 100644 index 000000000..e781958c8 --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request.json @@ -0,0 +1,8 @@ +{ + "content": [ + "foo", + "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", + "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + ], + "ttl": 3600 +} diff --git a/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json new file mode 100644 index 000000000..0acc314de --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet-request2.json @@ -0,0 +1,6 @@ +{ + "content": [ + "foo" + ], + "ttl": 3600 +} diff --git a/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json new file mode 100644 index 000000000..2b877982c --- /dev/null +++ b/providers/dns/leaseweb/internal/fixtures/updateResourceRecordSet.json @@ -0,0 +1,19 @@ +{ + "_links": { + "self": { + "href": "/domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT" + }, + "collection": { + "href": "/domains/example.com/resourceRecordSets" + } + }, + "content": [ + "foo", + "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo", + "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" + ], + "editable": true, + "name": "_acme-challenge.example.com.", + "ttl": 3600, + "type": "TXT" +} diff --git a/providers/dns/leaseweb/internal/types.go b/providers/dns/leaseweb/internal/types.go new file mode 100644 index 000000000..7a4547584 --- /dev/null +++ b/providers/dns/leaseweb/internal/types.go @@ -0,0 +1,35 @@ +package internal + +import ( + "encoding/json" + "fmt" +) + +type NotFoundError struct { + APIError +} + +type APIError struct { + CorrelationID string `json:"correlationId,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + ErrorDetails json.RawMessage `json:"errorDetails,omitempty"` +} + +func (a *APIError) Error() string { + msg := fmt.Sprintf("%s: %s (%s)", a.ErrorCode, a.ErrorMessage, a.CorrelationID) + + if len(a.ErrorDetails) > 0 { + msg += fmt.Sprintf(": %s", string(a.ErrorDetails)) + } + + return msg +} + +type RRSet struct { + Content []string `json:"content,omitempty"` + Name string `json:"name,omitempty"` + Editable bool `json:"editable,omitempty"` + TTL int `json:"ttl,omitempty"` + Type string `json:"type,omitempty"` +} diff --git a/providers/dns/leaseweb/leaseweb.go b/providers/dns/leaseweb/leaseweb.go new file mode 100644 index 000000000..fafaf1c4d --- /dev/null +++ b/providers/dns/leaseweb/leaseweb.go @@ -0,0 +1,187 @@ +// Package leaseweb implements a DNS provider for solving the DNS-01 challenge using Leaseweb. +package leaseweb + +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/internal/clientdebug" + "github.com/go-acme/lego/v4/providers/dns/leaseweb/internal" +) + +// Environment variables names. +const ( + envNamespace = "LEASEWEB_" + + EnvAPIKey = envNamespace + "API_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. +type Config struct { + APIKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Leaseweb. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("leaseweb: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Leaseweb. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("leaseweb: the configuration of the DNS provider is nil") + } + + client, err := internal.NewClient(config.APIKey) + if err != nil { + return nil, fmt.Errorf("leaseweb: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + client.HTTPClient = clientdebug.Wrap(client.HTTPClient) + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +// Present creates a TXT record using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("leaseweb: could not find zone for domain %q: %w", domain, err) + } + + existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT") + if err != nil { + notfoundErr := &internal.NotFoundError{} + if !errors.As(err, ¬foundErr) { + return fmt.Errorf("leaseweb: get RRSet: %w", err) + } + + // Create the RRSet. + + rrset := internal.RRSet{ + Content: []string{info.Value}, + Name: info.EffectiveFQDN, + TTL: internal.TTLRounder(d.config.TTL), + Type: "TXT", + } + + _, err = d.client.CreateRRSet(ctx, dns01.UnFqdn(authZone), rrset) + if err != nil { + return fmt.Errorf("leaseweb: create RRSet: %w", err) + } + + return nil + } + + // Update the RRSet. + + existingRRSet.Content = append(existingRRSet.Content, info.Value) + + _, err = d.client.UpdateRRSet(ctx, dns01.UnFqdn(authZone), *existingRRSet) + if err != nil { + return fmt.Errorf("leaseweb: update RRSet: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("leaseweb: could not find zone for domain %q: %w", domain, err) + } + + existingRRSet, err := d.client.GetRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT") + if err != nil { + return fmt.Errorf("leaseweb: get RRSet: %w", err) + } + + var content []string + + for _, s := range existingRRSet.Content { + if s != info.Value { + content = append(content, s) + } + } + + if len(content) == 0 { + err = d.client.DeleteRRSet(ctx, dns01.UnFqdn(authZone), info.EffectiveFQDN, "TXT") + if err != nil { + return fmt.Errorf("leaseweb: delete RRSet: %w", err) + } + + return nil + } + + existingRRSet.Content = content + + _, err = d.client.UpdateRRSet(ctx, dns01.UnFqdn(authZone), *existingRRSet) + if err != nil { + return fmt.Errorf("leaseweb: update RRSet: %w", err) + } + + 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 +} diff --git a/providers/dns/leaseweb/leaseweb.toml b/providers/dns/leaseweb/leaseweb.toml new file mode 100644 index 000000000..2c3503291 --- /dev/null +++ b/providers/dns/leaseweb/leaseweb.toml @@ -0,0 +1,22 @@ +Name = "Leaseweb" +Description = '''''' +URL = "https://www.leaseweb.com/en/" +Code = "leaseweb" +Since = "v4.32.0" + +Example = ''' +LEASEWEB_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ +lego --dns leaseweb -d '*.example.com' -d example.com run +''' + +[Configuration] + [Configuration.Credentials] + LEASEWEB_API_KEY = "API key" + [Configuration.Additional] + LEASEWEB_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 2)" + LEASEWEB_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" + LEASEWEB_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" + LEASEWEB_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" + +[Links] + API = "https://developer.leaseweb.com/docs/#tag/DNS" diff --git a/providers/dns/leaseweb/leaseweb_test.go b/providers/dns/leaseweb/leaseweb_test.go new file mode 100644 index 000000000..0450cd2c2 --- /dev/null +++ b/providers/dns/leaseweb/leaseweb_test.go @@ -0,0 +1,204 @@ +package leaseweb + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/go-acme/lego/v4/platform/tester/servermock" + "github.com/go-acme/lego/v4/providers/dns/leaseweb/internal" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIKey: "secret", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{}, + expected: "leaseweb: some credentials information are missing: LEASEWEB_API_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 + expected string + }{ + { + desc: "success", + apiKey: "secret", + }, + { + desc: "missing credentials", + expected: "leaseweb: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + + 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) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mockBuilder() *servermock.Builder[*DNSProvider] { + return servermock.NewBuilder( + func(server *httptest.Server) (*DNSProvider, error) { + config := NewDefaultConfig() + config.APIKey = "secret" + config.HTTPClient = server.Client() + + p, err := NewDNSProviderConfig(config) + if err != nil { + return nil, err + } + + p.client.BaseURL, _ = url.Parse(server.URL) + + return p, nil + }, + servermock.CheckHeader(). + WithJSONHeaders(). + With(internal.AuthHeader, "secret"), + ) +} + +func TestDNSProvider_Present_create(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("error_404.json"). + WithStatusCode(http.StatusNotFound), + ). + Route("POST /domains/example.com/resourceRecordSets", + servermock.ResponseFromInternal("createResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromInternal("createResourceRecordSet-request.json"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_Present_update(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("getResourceRecordSet.json"), + ). + Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("updateResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromInternal("updateResourceRecordSet-request.json"), + ). + Build(t) + + err := provider.Present("example.com", "abc", "123d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_delete(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("getResourceRecordSet2.json"), + ). + Route("DELETE /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.Noop(). + WithStatusCode(http.StatusNoContent), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "1234d==") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp_update(t *testing.T) { + provider := mockBuilder(). + Route("GET /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("getResourceRecordSet.json"), + ). + Route("PUT /domains/example.com/resourceRecordSets/_acme-challenge.example.com./TXT", + servermock.ResponseFromInternal("updateResourceRecordSet.json"), + servermock.CheckRequestJSONBodyFromInternal("updateResourceRecordSet-request2.json"), + ). + Build(t) + + err := provider.CleanUp("example.com", "abc", "1234d==") + require.NoError(t, err) +}