From ee33cff0020e336f58604fd7ad69d309b9d0de1e Mon Sep 17 00:00:00 2001 From: Van Hau TRAN Date: Thu, 23 Jan 2020 15:51:47 +0100 Subject: [PATCH] Add DNS provider for Scaleway (#1047) --- README.md | 6 +- cmd/zz_gen_cmd_dnshelp.go | 23 ++ docs/content/dns/zz_gen_scaleway.md | 64 +++++ providers/dns/dns_providers.go | 7 +- providers/dns/scaleway/internal/client.go | 228 ++++++++++++++++++ .../dns/scaleway/internal/client_test.go | 176 ++++++++++++++ providers/dns/scaleway/scaleway.go | 143 +++++++++++ providers/dns/scaleway/scaleway.toml | 24 ++ providers/dns/scaleway/scaleway_test.go | 131 ++++++++++ 9 files changed, 797 insertions(+), 5 deletions(-) create mode 100644 docs/content/dns/zz_gen_scaleway.md create mode 100644 providers/dns/scaleway/internal/client.go create mode 100644 providers/dns/scaleway/internal/client_test.go create mode 100644 providers/dns/scaleway/scaleway.go create mode 100644 providers/dns/scaleway/scaleway.toml create mode 100644 providers/dns/scaleway/scaleway_test.go diff --git a/README.md b/README.md index fb8bf88b6..20baf10bb 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,6 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | | [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | -| [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | -| [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | -| [Versio](https://go-acme.github.io/lego/dns/versio/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | | +| [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | +| [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | +| [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Versio](https://go-acme.github.io/lego/dns/versio/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index ee2531b0a..63108e3e7 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -68,6 +68,7 @@ func allDNSCodes() string { "rfc2136", "route53", "sakuracloud", + "scaleway", "selectel", "stackpath", "transip", @@ -1237,6 +1238,28 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/sakuracloud`) + case "scaleway": + // generated from: providers/dns/scaleway/scaleway.toml + ew.writeln(`Configuration for Scaleway.`) + ew.writeln(`Code: 'scaleway'`) + ew.writeln(`Since: 'v3.4.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "SCALEWAY_API_TOKEN": API token`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "SCALEWAY_API_VERSION": API version`) + ew.writeln(` - "SCALEWAY_BASE_URL": API endpoint URL`) + ew.writeln(` - "SCALEWAY_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "SCALEWAY_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "SCALEWAY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "SCALEWAY_TTL": The TTL of the TXT record used for the DNS challenge`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/scaleway`) + case "selectel": // generated from: providers/dns/selectel/selectel.toml ew.writeln(`Configuration for Selectel.`) diff --git a/docs/content/dns/zz_gen_scaleway.md b/docs/content/dns/zz_gen_scaleway.md new file mode 100644 index 000000000..930d284a2 --- /dev/null +++ b/docs/content/dns/zz_gen_scaleway.md @@ -0,0 +1,64 @@ +--- +title: "Scaleway" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: scaleway +--- + + + + + +Since: v3.4.0 + +Configuration for [Scaleway](https://developers.scaleway.com/). + + + + +- Code: `scaleway` + +Here is an example bash command using the Scaleway provider: + +```bash +SCALEWAY_API_TOKEN=xxxxxxx-xxxxx-xxxx-xxx-xxxxxx \ +lego --dns scaleway.com --domains my.domain.com --email my@email.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `SCALEWAY_API_TOKEN` | API token | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here](/lego/dns/#configuration-and-credentials). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `SCALEWAY_API_VERSION` | API version | +| `SCALEWAY_BASE_URL` | API endpoint URL | +| `SCALEWAY_HTTP_TIMEOUT` | API request timeout | +| `SCALEWAY_POLLING_INTERVAL` | Time between DNS propagation check | +| `SCALEWAY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `SCALEWAY_TTL` | The TTL of the TXT record used for the DNS challenge | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here](/lego/dns/#configuration-and-credentials). + + + + +## More information + +- [API documentation](https://developers.scaleway.com/en/products/domain/api/) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 9f5c2d836..e7d4ac908 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -59,6 +59,7 @@ import ( "github.com/go-acme/lego/v3/providers/dns/rfc2136" "github.com/go-acme/lego/v3/providers/dns/route53" "github.com/go-acme/lego/v3/providers/dns/sakuracloud" + "github.com/go-acme/lego/v3/providers/dns/scaleway" "github.com/go-acme/lego/v3/providers/dns/selectel" "github.com/go-acme/lego/v3/providers/dns/stackpath" "github.com/go-acme/lego/v3/providers/dns/transip" @@ -182,10 +183,12 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return rfc2136.NewDNSProvider() case "sakuracloud": return sakuracloud.NewDNSProvider() - case "stackpath": - return stackpath.NewDNSProvider() + case "scaleway": + return scaleway.NewDNSProvider() case "selectel": return selectel.NewDNSProvider() + case "stackpath": + return stackpath.NewDNSProvider() case "transip": return transip.NewDNSProvider() case "vegadns": diff --git a/providers/dns/scaleway/internal/client.go b/providers/dns/scaleway/internal/client.go new file mode 100644 index 000000000..daf3f9f67 --- /dev/null +++ b/providers/dns/scaleway/internal/client.go @@ -0,0 +1,228 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" +) + +const ( + defaultEndpoint = "https://api.scaleway.com/domain/v2alpha2" + uriUpdateRecords = "/dns-zones/%s/records" + operationSet = "set" + operationDelete = "delete" + operationAdd = "add" +) + +// APIError represents an error response from the API. +type APIError struct { + Message string `json:"message"` +} + +func (a APIError) Error() string { + return a.Message +} + +// Record represents a DNS record +type Record struct { + Data string `json:"data,omitempty"` + Name string `json:"name,omitempty"` + Priority uint32 `json:"priority,omitempty"` + TTL uint32 `json:"ttl,omitempty"` + Type string `json:"type,omitempty"` + Comment string `json:"comment,omitempty"` +} + +// RecordChangeAdd represents a list of add operations. +type RecordChangeAdd struct { + Records []*Record `json:"records,omitempty"` +} + +// RecordChangeSet represents a list of set operations. +type RecordChangeSet struct { + Data string `json:"data,omitempty"` + Name string `json:"name,omitempty"` + TTL uint32 `json:"ttl,omitempty"` + Type string `json:"type,omitempty"` + Records []*Record `json:"records,omitempty"` +} + +// RecordChangeDelete represents a list of delete operations. +type RecordChangeDelete struct { + Data string `json:"data,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` +} + +// UpdateDNSZoneRecordsRequest represents a request to update DNS records on the API. +type UpdateDNSZoneRecordsRequest struct { + DNSZone string `json:"dns_zone,omitempty"` + Changes []interface{} `json:"changes,omitempty"` + ReturnAllRecords bool `json:"return_all_records,omitempty"` +} + +// ClientOpts represents options to init client. +type ClientOpts struct { + BaseURL string + Token string +} + +// Client represents DNS client. +type Client struct { + baseURL string + token string + httpClient *http.Client +} + +// NewClient returns a client instance. +func NewClient(opts ClientOpts, httpClient *http.Client) *Client { + baseURL := defaultEndpoint + if opts.BaseURL != "" { + baseURL = opts.BaseURL + } + + if httpClient == nil { + httpClient = &http.Client{} + } + + return &Client{ + token: opts.Token, + baseURL: baseURL, + httpClient: httpClient, + } +} + +// AddRecord adds Record for given zone. +func (c *Client) AddRecord(zone string, record Record) error { + changes := map[string]RecordChangeAdd{ + operationAdd: { + Records: []*Record{&record}, + }, + } + + request := UpdateDNSZoneRecordsRequest{ + DNSZone: zone, + Changes: []interface{}{changes}, + ReturnAllRecords: false, + } + + uri := fmt.Sprintf(uriUpdateRecords, zone) + req, err := c.newRequest(http.MethodPatch, uri, request) + if err != nil { + return err + } + + return c.do(req) +} + +// SetRecord sets a unique Record for given zone. +func (c *Client) SetRecord(zone string, record Record) error { + changes := map[string]RecordChangeSet{ + operationSet: { + Name: record.Name, + Type: record.Type, + Records: []*Record{&record}, + }, + } + + request := UpdateDNSZoneRecordsRequest{ + DNSZone: zone, + Changes: []interface{}{changes}, + ReturnAllRecords: false, + } + + uri := fmt.Sprintf(uriUpdateRecords, zone) + req, err := c.newRequest(http.MethodPatch, uri, request) + if err != nil { + return err + } + + return c.do(req) +} + +// DeleteRecord deletes a Record for given zone. +func (c *Client) DeleteRecord(zone string, record Record) error { + delRecord := map[string]RecordChangeDelete{ + operationDelete: { + Name: record.Name, + Type: record.Type, + Data: record.Data, + }, + } + + request := UpdateDNSZoneRecordsRequest{ + DNSZone: zone, + Changes: []interface{}{delRecord}, + ReturnAllRecords: false, + } + + uri := fmt.Sprintf(uriUpdateRecords, zone) + req, err := c.newRequest(http.MethodPatch, uri, request) + if err != nil { + return err + } + + return c.do(req) +} + +func (c *Client) newRequest(method, uri string, body interface{}) (*http.Request, error) { + buf := new(bytes.Buffer) + + if body != nil { + err := json.NewEncoder(buf).Encode(body) + if err != nil { + return nil, fmt.Errorf("failed to encode request body with error: %w", err) + } + } + + req, err := http.NewRequest(method, c.baseURL+uri, buf) + if err != nil { + return nil, fmt.Errorf("failed to create new http request with error: %w", err) + } + + req.Header.Add("X-auth-token", c.token) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + + return req, nil +} + +func (c *Client) do(req *http.Request) error { + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed with error: %w", err) + } + + err = checkResponse(resp) + if err != nil { + return err + } + + return checkResponse(resp) +} + +func checkResponse(resp *http.Response) error { + if resp.StatusCode >= http.StatusBadRequest || resp.StatusCode < http.StatusOK { + if resp.Body == nil { + return fmt.Errorf("request failed with status code %d and empty body", resp.StatusCode) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + defer resp.Body.Close() + + apiError := APIError{} + err = json.Unmarshal(body, &apiError) + if err != nil { + return fmt.Errorf("request failed with status code %d, response body: %s", resp.StatusCode, string(body)) + } + + return fmt.Errorf("request failed with status code %d: %w", resp.StatusCode, apiError) + } + + return nil +} diff --git a/providers/dns/scaleway/internal/client_test.go b/providers/dns/scaleway/internal/client_test.go new file mode 100644 index 000000000..4d7df4875 --- /dev/null +++ b/providers/dns/scaleway/internal/client_test.go @@ -0,0 +1,176 @@ +package internal + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const fakeToken = "test" + +func setupTest() (*Client, *http.ServeMux, func()) { + mux := http.NewServeMux() + svr := httptest.NewServer(mux) + + opts := ClientOpts{ + BaseURL: svr.URL, + Token: fakeToken, + } + client := NewClient(opts, nil) + + return client, mux, func() { + svr.Close() + } +} + +func TestClient_AddRecord(t *testing.T) { + client, mux, tearDown := setupTest() + defer tearDown() + + mux.HandleFunc("/dns-zones/zone/records", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPatch { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) + return + } + + auth := req.Header.Get("X-Auth-Token") + if auth != fakeToken { + http.Error(rw, fmt.Sprintf("invalid token: %s", auth), http.StatusUnauthorized) + return + } + + raw, err := ioutil.ReadAll(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + expected := `{"dns_zone":"zone","changes":[{"add":{"records":[{"data":"\"value\"","name":"fqdn","ttl":30,"type":"TXT"}]}}]}` + assert.Equal(t, expected+"\n", string(raw)) + }) + + record := Record{ + Type: "TXT", + TTL: 30, + Name: "fqdn", + Data: fmt.Sprintf(`"%s"`, "value"), + } + + err := client.AddRecord("zone", record) + require.NoError(t, err) +} + +func TestClient_AddRecord_error(t *testing.T) { + client, mux, tearDown := setupTest() + defer tearDown() + + mux.HandleFunc("/dns-zones/zone/records", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPatch { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) + return + } + + auth := req.Header.Get("X-Auth-Token") + if auth != fakeToken { + http.Error(rw, fmt.Sprintf("invalid token: %s", auth), http.StatusUnauthorized) + return + } + + rw.WriteHeader(http.StatusNotFound) + err := json.NewEncoder(rw).Encode(APIError{"oops"}) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + record := Record{ + Type: "TXT", + TTL: 30, + Name: "fqdn", + Data: fmt.Sprintf(`"%s"`, "value"), + } + + err := client.AddRecord("zone", record) + require.EqualError(t, err, "request failed with status code 404: oops") +} + +func TestClient_SetRecord(t *testing.T) { + client, mux, tearDown := setupTest() + defer tearDown() + + mux.HandleFunc("/dns-zones/zone/records", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPatch { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) + return + } + + auth := req.Header.Get("X-Auth-Token") + if auth != fakeToken { + http.Error(rw, fmt.Sprintf("invalid token: %s", auth), http.StatusUnauthorized) + return + } + + raw, err := ioutil.ReadAll(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + expected := `{"dns_zone":"zone","changes":[{"set":{"name":"fqdn","type":"TXT","records":[{"data":"\"value\"","name":"fqdn","ttl":30,"type":"TXT"}]}}]}` + assert.Equal(t, expected+"\n", string(raw)) + }) + + record := Record{ + Type: "TXT", + TTL: 30, + Name: "fqdn", + Data: fmt.Sprintf(`"%s"`, "value"), + } + + err := client.SetRecord("zone", record) + require.NoError(t, err) +} + +func TestClient_DeleteRecord(t *testing.T) { + client, mux, tearDown := setupTest() + defer tearDown() + + mux.HandleFunc("/dns-zones/zone/records", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPatch { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusBadRequest) + return + } + + auth := req.Header.Get("X-Auth-Token") + if auth != fakeToken { + http.Error(rw, fmt.Sprintf("invalid token: %s", auth), http.StatusUnauthorized) + return + } + + raw, err := ioutil.ReadAll(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + expected := `{"dns_zone":"zone","changes":[{"delete":{"data":"\"value\"","name":"fqdn","type":"TXT"}}]}` + assert.Equal(t, expected+"\n", string(raw)) + }) + + record := Record{ + Type: "TXT", + TTL: 30, + Name: "fqdn", + Data: fmt.Sprintf(`"%s"`, "value"), + } + + err := client.DeleteRecord("zone", record) + require.NoError(t, err) +} diff --git a/providers/dns/scaleway/scaleway.go b/providers/dns/scaleway/scaleway.go new file mode 100644 index 000000000..3107db5a3 --- /dev/null +++ b/providers/dns/scaleway/scaleway.go @@ -0,0 +1,143 @@ +// Package scaleway implements a DNS provider for solving the DNS-01 challenge using Scaleway Domains API. +// Scaleway Domain API reference: https://developers.scaleway.com/en/products/domain/api/ +// Token: https://www.scaleway.com/en/docs/generate-an-api-token/ +package scaleway + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v3/challenge/dns01" + "github.com/go-acme/lego/v3/platform/config/env" + "github.com/go-acme/lego/v3/providers/dns/scaleway/internal" +) + +const ( + defaultBaseURL = "https://api.scaleway.com" + defaultVersion = "v2alpha2" + minTTL = 60 + defaultPollingInterval = 10 * time.Second + defaultPropagationTimeout = 120 * time.Second +) + +const ( + envNamespace = "SCALEWAY_" + baseURLEnvVar = envNamespace + "BASE_URL" + apiTokenEnvVar = envNamespace + "API_TOKEN" + apiVersionEnvVar = envNamespace + "API_VERSION" + ttlEnvVar = envNamespace + "TTL" + propagationTimeoutEnvVar = envNamespace + "PROPAGATION_TIMEOUT" + pollingIntervalEnvVar = envNamespace + "POLLING_INTERVAL" + httpTimeoutEnvVar = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + BaseURL string + Version string + Token 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{ + BaseURL: env.GetOrDefaultString(baseURLEnvVar, defaultBaseURL), + Version: env.GetOrDefaultString(apiVersionEnvVar, defaultVersion), + TTL: env.GetOrDefaultInt(ttlEnvVar, minTTL), + PropagationTimeout: env.GetOrDefaultSecond(propagationTimeoutEnvVar, defaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(pollingIntervalEnvVar, defaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(httpTimeoutEnvVar, 30*time.Second), + }, + } +} + +// DNSProvider is an implementation of the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Scaleway Domains API. +// API token must be passed in the environment variable SCALEWAY_API_TOKEN. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(apiTokenEnvVar) + if err != nil { + return nil, fmt.Errorf("scaleway: %w", err) + } + + config := NewDefaultConfig() + config.Token = values[apiTokenEnvVar] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for scaleway. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("scaleway: the configuration of the DNS provider is nil") + } + + if config.Token == "" { + return nil, errors.New("scaleway: credentials missing") + } + + if config.TTL < minTTL { + config.TTL = minTTL + } + + client := internal.NewClient(internal.ClientOpts{ + BaseURL: fmt.Sprintf("%s/domain/%s", config.BaseURL, config.Version), + Token: config.Token, + }, config.HTTPClient) + + return &DNSProvider{config: config, client: client}, 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 +} + +// Present creates a TXT record to fulfill DNS-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + txtRecord := internal.Record{ + Type: "TXT", + TTL: uint32(d.config.TTL), + Name: fqdn, + Data: fmt.Sprintf(`"%s"`, value), + } + + err := d.client.AddRecord(domain, txtRecord) + if err != nil { + return fmt.Errorf("scaleway: %w", err) + } + return nil +} + +// CleanUp removes a TXT record used for DNS-01 challenge. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + txtRecord := internal.Record{ + Type: "TXT", + TTL: uint32(d.config.TTL), + Name: fqdn, + Data: fmt.Sprintf(`"%s"`, value), + } + + err := d.client.DeleteRecord(domain, txtRecord) + if err != nil { + return fmt.Errorf("scaleway: %w", err) + } + return nil +} diff --git a/providers/dns/scaleway/scaleway.toml b/providers/dns/scaleway/scaleway.toml new file mode 100644 index 000000000..d4683662a --- /dev/null +++ b/providers/dns/scaleway/scaleway.toml @@ -0,0 +1,24 @@ +Name = "Scaleway" +Description = '''''' +URL = "https://developers.scaleway.com/" +Code = "scaleway" +Since = "v3.4.0" + +Example = ''' +SCALEWAY_API_TOKEN=xxxxxxx-xxxxx-xxxx-xxx-xxxxxx \ +lego --dns scaleway.com --domains my.domain.com --email my@email.com run +''' + +[Configuration] + [Configuration.Credentials] + SCALEWAY_API_TOKEN = "API token" + [Configuration.Additional] + SCALEWAY_BASE_URL = "API endpoint URL" + SCALEWAY_API_VERSION = "API version" + SCALEWAY_POLLING_INTERVAL = "Time between DNS propagation check" + SCALEWAY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + SCALEWAY_TTL = "The TTL of the TXT record used for the DNS challenge" + SCALEWAY_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://developers.scaleway.com/en/products/domain/api/" diff --git a/providers/dns/scaleway/scaleway_test.go b/providers/dns/scaleway/scaleway_test.go new file mode 100644 index 000000000..f3fe9b5cb --- /dev/null +++ b/providers/dns/scaleway/scaleway_test.go @@ -0,0 +1,131 @@ +package scaleway + +import ( + "fmt" + "testing" + "time" + + "github.com/go-acme/lego/v3/platform/tester" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + cleanUpDelay = 2 * time.Second +) + +func TestNewDNSProvider(t *testing.T) { + var envTest = tester.NewEnvTest(apiTokenEnvVar, ttlEnvVar) + + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + apiTokenEnvVar: "123", + }, + }, + { + desc: "missing api key", + envVars: map[string]string{ + apiTokenEnvVar: "", + }, + expected: fmt.Sprintf("scaleway: some credentials information are missing: %s", apiTokenEnvVar), + }, + } + + 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 len(test.expected) == 0 { + require.NoError(t, err) + require.NotNil(t, p) + assert.NotNil(t, p.config) + assert.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + token string + ttl int + expected string + }{ + { + desc: "success", + token: "123", + ttl: minTTL, + }, + { + desc: "missing api key", + token: "", + ttl: minTTL, + expected: "scaleway: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.TTL = test.ttl + config.Token = test.token + + p, err := NewDNSProviderConfig(config) + + if len(test.expected) == 0 { + require.NoError(t, err) + require.NotNil(t, p) + assert.NotNil(t, p.config) + assert.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + var envTest = tester.NewEnvTest(apiTokenEnvVar, ttlEnvVar) + + 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) { + var envTest = tester.NewEnvTest(apiTokenEnvVar, ttlEnvVar) + + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + provider, err := NewDNSProvider() + require.NoError(t, err) + + time.Sleep(cleanUpDelay) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +}