diff --git a/README.md b/README.md index ae174fe6..41cb3b46 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,9 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [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/) | [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) | | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [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/) | -| [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | [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/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | -| [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | -| [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | | | +| [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | [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/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | +| [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex](https://go-acme.github.io/lego/dns/yandex/) | +| [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 2918cca2..39344a66 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -90,6 +90,7 @@ func allDNSCodes() string { "scaleway", "selectel", "servercow", + "simply", "sonic", "stackpath", "transip", @@ -1729,6 +1730,27 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/servercow`) + case "simply": + // generated from: providers/dns/simply/simply.toml + ew.writeln(`Configuration for Simply.com.`) + ew.writeln(`Code: 'simply'`) + ew.writeln(`Since: 'v4.4.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "SIMPLY_ACCOUNT_NAME": Account name`) + ew.writeln(` - "SIMPLY_API_KEY": API key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "SIMPLY_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "SIMPLY_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "SIMPLY_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "SIMPLY_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/simply`) + case "sonic": // generated from: providers/dns/sonic/sonic.toml ew.writeln(`Configuration for Sonic.`) diff --git a/docs/content/dns/zz_gen_simply.md b/docs/content/dns/zz_gen_simply.md new file mode 100644 index 00000000..2fefe746 --- /dev/null +++ b/docs/content/dns/zz_gen_simply.md @@ -0,0 +1,64 @@ +--- +title: "Simply.com" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: simply +--- + + + + + +Since: v4.4.0 + +Configuration for [Simply.com](https://www.simply.com/en/domains/). + + + + +- Code: `simply` + +Here is an example bash command using the Simply.com provider: + +```bash +SIMPLY_ACCOUNT_NAME=xxxxxx \ +SIMPLY_API_KEY=yyyyyy \ +lego --email myemail@example.com --dns simply --domains my.example.org run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `SIMPLY_ACCOUNT_NAME` | Account name | +| `SIMPLY_API_KEY` | API key | + +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 | +|--------------------------------|-------------| +| `SIMPLY_HTTP_TIMEOUT` | API request timeout | +| `SIMPLY_POLLING_INTERVAL` | Time between DNS propagation check | +| `SIMPLY_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `SIMPLY_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://www.simply.com/en/docs/api/) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 870adb9d..500ac11e 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -81,6 +81,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/scaleway" "github.com/go-acme/lego/v4/providers/dns/selectel" "github.com/go-acme/lego/v4/providers/dns/servercow" + "github.com/go-acme/lego/v4/providers/dns/simply" "github.com/go-acme/lego/v4/providers/dns/sonic" "github.com/go-acme/lego/v4/providers/dns/stackpath" "github.com/go-acme/lego/v4/providers/dns/transip" @@ -252,6 +253,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return selectel.NewDNSProvider() case "servercow": return servercow.NewDNSProvider() + case "simply": + return simply.NewDNSProvider() case "sonic": return sonic.NewDNSProvider() case "stackpath": diff --git a/providers/dns/infomaniak/internal/client.go b/providers/dns/infomaniak/internal/client.go index 5f197624..4234a041 100644 --- a/providers/dns/infomaniak/internal/client.go +++ b/providers/dns/infomaniak/internal/client.go @@ -10,6 +10,7 @@ import ( "net/url" "path" "strings" + "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/log" @@ -27,7 +28,7 @@ func New(apiEndpoint, apiToken string) *Client { return &Client{ apiEndpoint: apiEndpoint, apiToken: apiToken, - HTTPClient: &http.Client{}, + HTTPClient: &http.Client{Timeout: 5 * time.Second}, } } diff --git a/providers/dns/simply/internal/client.go b/providers/dns/simply/internal/client.go new file mode 100644 index 00000000..a3c0af0d --- /dev/null +++ b/providers/dns/simply/internal/client.go @@ -0,0 +1,137 @@ +package internal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "time" +) + +const defaultBaseURL = "https://api.simply.com/1/" + +// Client is a Simply.com API client. +type Client struct { + HTTPClient *http.Client + baseURL *url.URL + accountName string + apiKey string +} + +// NewClient creates a new Client. +func NewClient(accountName string, apiKey string) (*Client, error) { + if accountName == "" { + return nil, errors.New("credentials missing: accountName") + } + + if apiKey == "" { + return nil, errors.New("credentials missing: apiKey") + } + + baseURL, err := url.Parse(defaultBaseURL) + if err != nil { + return nil, err + } + + return &Client{ + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + baseURL: baseURL, + accountName: accountName, + apiKey: apiKey, + }, nil +} + +// GetRecords lists all the records in the zone. +func (c *Client) GetRecords(zoneName string) ([]Record, error) { + resp, err := c.do(zoneName, "/", http.MethodGet, nil) + if err != nil { + return nil, err + } + + var records []Record + err = json.Unmarshal(resp.Records, &records) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response result: %w", err) + } + + return records, nil +} + +// AddRecord adds a record. +func (c *Client) AddRecord(zoneName string, record Record) (int64, error) { + reqBody, err := json.Marshal(record) + if err != nil { + return 0, fmt.Errorf("failed to marshall request body: %w", err) + } + + resp, err := c.do(zoneName, "/", http.MethodPost, reqBody) + if err != nil { + return 0, err + } + + var rcd recordHeader + err = json.Unmarshal(resp.Record, &rcd) + if err != nil { + return 0, fmt.Errorf("failed to unmarshal response result: %w", err) + } + + return rcd.ID, nil +} + +// EditRecord updates a record. +func (c *Client) EditRecord(zoneName string, id int64, record Record) error { + reqBody, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("failed to marshall request body: %w", err) + } + + _, err = c.do(zoneName, fmt.Sprintf("%d", id), http.MethodPut, reqBody) + return err +} + +// DeleteRecord deletes a record. +func (c *Client) DeleteRecord(zoneName string, id int64) error { + _, err := c.do(zoneName, fmt.Sprintf("%d", id), http.MethodDelete, nil) + return err +} + +func (c *Client) do(zoneName string, endpoint string, reqMethod string, reqBody []byte) (*apiResponse, error) { + reqURL, err := c.baseURL.Parse(path.Join(c.baseURL.Path, c.accountName, c.apiKey, "my", "products", zoneName, "dns", "records", endpoint)) + if err != nil { + return nil, fmt.Errorf("failed to parse endpoint: %w", err) + } + + req, err := http.NewRequest(reqMethod, reqURL.String(), bytes.NewReader(reqBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to perform request: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= http.StatusInternalServerError { + return nil, fmt.Errorf("unexpected error: %d", resp.StatusCode) + } + + response := apiResponse{} + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + if response.Status != http.StatusOK { + return nil, fmt.Errorf("unexpected error: %s", response.Message) + } + + return &response, nil +} diff --git a/providers/dns/simply/internal/client_test.go b/providers/dns/simply/internal/client_test.go new file mode 100644 index 00000000..575ada9c --- /dev/null +++ b/providers/dns/simply/internal/client_test.go @@ -0,0 +1,207 @@ +package internal + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_GetRecords(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodGet, http.StatusOK, "get_records.json")) + + records, err := client.GetRecords("azone01") + require.NoError(t, err) + + expected := []Record{ + { + ID: 1, + Name: "@", + TTL: 3600, + Data: "ns1.simply.com", + Type: "NS", + Priority: 0, + }, + { + ID: 2, + Name: "@", + TTL: 3600, + Data: "ns2.simply.com", + Type: "NS", + Priority: 0, + }, + { + ID: 3, + Name: "@", + TTL: 3600, + Data: "ns3.simply.com", + Type: "NS", + Priority: 0, + }, + { + ID: 4, + Name: "@", + TTL: 3600, + Data: "ns4.simply.com", + Type: "NS", + Priority: 0, + }, + } + + assert.Equal(t, expected, records) +} + +func TestClient_GetRecords_error(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodGet, http.StatusBadRequest, "bad_auth_error.json")) + + records, err := client.GetRecords("azone01") + require.Error(t, err) + + assert.Nil(t, records) +} + +func TestClient_AddRecord(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodPost, http.StatusOK, "add_record.json")) + + record := Record{ + Name: "arecord01", + Data: "content", + Type: "TXT", + TTL: 120, + Priority: 0, + } + + recordID, err := client.AddRecord("azone01", record) + require.NoError(t, err) + + assert.EqualValues(t, 123456789, recordID) +} + +func TestClient_AddRecord_error(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records", mockHandler(http.MethodPost, http.StatusNotFound, "bad_zone_error.json")) + + record := Record{ + Name: "arecord01", + Data: "content", + Type: "TXT", + TTL: 120, + Priority: 0, + } + + recordID, err := client.AddRecord("azone01", record) + require.Error(t, err) + + assert.Zero(t, recordID) +} + +func TestClient_EditRecord(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodPut, http.StatusOK, "success.json")) + + record := Record{ + Name: "arecord01", + Data: "content", + Type: "TXT", + TTL: 120, + Priority: 0, + } + + err := client.EditRecord("azone01", 123456789, record) + require.NoError(t, err) +} + +func TestClient_EditRecord_error(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodPut, http.StatusNotFound, "invalid_record_id.json")) + + record := Record{ + Name: "arecord01", + Data: "content", + Type: "TXT", + TTL: 120, + Priority: 0, + } + + err := client.EditRecord("azone01", 123456789, record) + require.Error(t, err) +} + +func TestClient_DeleteRecord(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodDelete, http.StatusOK, "success.json")) + + err := client.DeleteRecord("azone01", 123456789) + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + mux, client := setupTest(t) + + mux.HandleFunc("/accountname/apikey/my/products/azone01/dns/records/123456789", mockHandler(http.MethodDelete, http.StatusNotFound, "invalid_record_id.json")) + + err := client.DeleteRecord("azone01", 123456789) + require.Error(t, err) +} + +func setupTest(t *testing.T) (*http.ServeMux, *Client) { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + client, err := NewClient("accountname", "apikey") + require.NoError(t, err) + + client.baseURL, _ = url.Parse(server.URL) + + return mux, client +} + +func mockHandler(method string, statusCode int, filename string) func(http.ResponseWriter, *http.Request) { + return func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + if filename == "" { + rw.WriteHeader(statusCode) + return + } + + file, err := os.Open(filepath.FromSlash(path.Join("./fixtures", filename))) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + rw.WriteHeader(statusCode) + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + } +} diff --git a/providers/dns/simply/internal/fixtures/add_record.json b/providers/dns/simply/internal/fixtures/add_record.json new file mode 100644 index 00000000..910d0236 --- /dev/null +++ b/providers/dns/simply/internal/fixtures/add_record.json @@ -0,0 +1,7 @@ +{ + "status": 200, + "message": "success", + "record": { + "id": 123456789 + } +} diff --git a/providers/dns/simply/internal/fixtures/bad_auth_error.json b/providers/dns/simply/internal/fixtures/bad_auth_error.json new file mode 100644 index 00000000..2c18f975 --- /dev/null +++ b/providers/dns/simply/internal/fixtures/bad_auth_error.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "message": "Invalid account authorization" +} diff --git a/providers/dns/simply/internal/fixtures/bad_zone_error.json b/providers/dns/simply/internal/fixtures/bad_zone_error.json new file mode 100644 index 00000000..f45850e4 --- /dev/null +++ b/providers/dns/simply/internal/fixtures/bad_zone_error.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "message": "Unknown or invalid product reference" +} diff --git a/providers/dns/simply/internal/fixtures/get_records.json b/providers/dns/simply/internal/fixtures/get_records.json new file mode 100644 index 00000000..62342efe --- /dev/null +++ b/providers/dns/simply/internal/fixtures/get_records.json @@ -0,0 +1,38 @@ +{ + "status": 200, + "message": "success", + "records": [ + { + "record_id": 1, + "name": "@", + "ttl": 3600, + "data": "ns1.simply.com", + "type": "NS", + "priority": 0 + }, + { + "record_id": 2, + "name": "@", + "ttl": 3600, + "data": "ns2.simply.com", + "type": "NS", + "priority": 0 + }, + { + "record_id": 3, + "name": "@", + "ttl": 3600, + "data": "ns3.simply.com", + "type": "NS", + "priority": 0 + }, + { + "record_id": 4, + "name": "@", + "ttl": 3600, + "data": "ns4.simply.com", + "type": "NS", + "priority": 0 + } + ] +} diff --git a/providers/dns/simply/internal/fixtures/invalid_record_id_error.json b/providers/dns/simply/internal/fixtures/invalid_record_id_error.json new file mode 100644 index 00000000..73529870 --- /dev/null +++ b/providers/dns/simply/internal/fixtures/invalid_record_id_error.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "message": "Unknown DNS record" +} diff --git a/providers/dns/simply/internal/fixtures/success.json b/providers/dns/simply/internal/fixtures/success.json new file mode 100644 index 00000000..bbc0438a --- /dev/null +++ b/providers/dns/simply/internal/fixtures/success.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "message": "success" +} diff --git a/providers/dns/simply/internal/types.go b/providers/dns/simply/internal/types.go new file mode 100644 index 00000000..e2440c31 --- /dev/null +++ b/providers/dns/simply/internal/types.go @@ -0,0 +1,25 @@ +package internal + +import "encoding/json" + +// Record represents the content of a DNS record. +type Record struct { + ID int64 `json:"record_id,omitempty"` + Name string `json:"name,omitempty"` + Data string `json:"data,omitempty"` + Type string `json:"type,omitempty"` + TTL int `json:"ttl,omitempty"` + Priority int `json:"priority,omitempty"` +} + +// apiResponse represents an API response. +type apiResponse struct { + Status int `json:"status"` + Message string `json:"message"` + Records json.RawMessage `json:"records,omitempty"` + Record json.RawMessage `json:"record,omitempty"` +} + +type recordHeader struct { + ID int64 `json:"id"` +} diff --git a/providers/dns/simply/simply.go b/providers/dns/simply/simply.go new file mode 100644 index 00000000..030430d6 --- /dev/null +++ b/providers/dns/simply/simply.go @@ -0,0 +1,172 @@ +// Package simply implements a DNS provider for solving the DNS-01 challenge using Simply.com. +package simply + +import ( + "errors" + "fmt" + "net/http" + "strings" + "sync" + "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/simply/internal" +) + +// Environment variables names. +const ( + envNamespace = "SIMPLY_" + + EnvAccountName = envNamespace + "ACCOUNT_NAME" + 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 { + AccountName string + 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, 5*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + recordIDs map[string]int64 + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Njalla. +// Credentials must be passed in the environment variable: NJALLA_TOKEN. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAccountName, EnvAPIKey) + if err != nil { + return nil, fmt.Errorf("simply: %w", err) + } + + config := NewDefaultConfig() + config.AccountName = values[EnvAccountName] + config.APIKey = values[EnvAPIKey] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Njalla. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("simply: the configuration of the DNS provider is nil") + } + + if config.AccountName == "" { + return nil, errors.New("simply: missing credentials: account name") + } + + if config.APIKey == "" { + return nil, errors.New("simply: missing credentials: api key") + } + + client, err := internal.NewClient(config.AccountName, config.APIKey) + if err != nil { + return nil, fmt.Errorf("simply: failed to create client: %w", err) + } + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]int64), + }, 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 using the specified parameters. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("simply: could not determine zone for domain %q: %w", domain, err) + } + authZone = dns01.UnFqdn(authZone) + + subDomain := dns01.UnFqdn(strings.TrimSuffix(fqdn, authZone)) + + recordBody := internal.Record{ + Name: subDomain, + Data: value, + Type: "TXT", + TTL: d.config.TTL, + } + + recordID, err := d.client.AddRecord(authZone, recordBody) + if err != nil { + return fmt.Errorf("simply: failed to add record: %w", err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = recordID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _ := dns01.GetRecord(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("simply: could not determine zone for domain %q: %w", domain, err) + } + authZone = dns01.UnFqdn(authZone) + + // gets the record's unique ID from when we created it + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + if !ok { + return fmt.Errorf("simply: unknown record ID for '%s' '%s'", fqdn, token) + } + + err = d.client.DeleteRecord(authZone, recordID) + if err != nil { + return fmt.Errorf("simply: failed to delete TXT records: fqdn=%s, recordID=%d: %w", fqdn, recordID, err) + } + + // deletes record ID from map + d.recordIDsMu.Lock() + delete(d.recordIDs, token) + d.recordIDsMu.Unlock() + + return nil +} diff --git a/providers/dns/simply/simply.toml b/providers/dns/simply/simply.toml new file mode 100644 index 00000000..173c75f8 --- /dev/null +++ b/providers/dns/simply/simply.toml @@ -0,0 +1,24 @@ +Name = "Simply.com" +Description = '''''' +URL = "https://www.simply.com/en/domains/" +Code = "simply" +Since = "v4.4.0" + +Example = ''' +SIMPLY_ACCOUNT_NAME=xxxxxx \ +SIMPLY_API_KEY=yyyyyy \ +lego --email myemail@example.com --dns simply --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + SIMPLY_ACCOUNT_NAME = "Account name" + SIMPLY_API_KEY = "API key" + [Configuration.Additional] + SIMPLY_POLLING_INTERVAL = "Time between DNS propagation check" + SIMPLY_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + SIMPLY_TTL = "The TTL of the TXT record used for the DNS challenge" + SIMPLY_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://www.simply.com/en/docs/api/" diff --git a/providers/dns/simply/simply_test.go b/providers/dns/simply/simply_test.go new file mode 100644 index 00000000..ace8e0b7 --- /dev/null +++ b/providers/dns/simply/simply_test.go @@ -0,0 +1,142 @@ +package simply + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAccountName, EnvAPIKey).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAccountName: "S000000", + EnvAPIKey: "secret", + }, + }, + { + desc: "missing credentials: account name", + envVars: map[string]string{ + EnvAccountName: "", + EnvAPIKey: "secret", + }, + expected: "simply: some credentials information are missing: SIMPLY_ACCOUNT_NAME", + }, + { + desc: "missing credentials: api key", + envVars: map[string]string{ + EnvAccountName: "S000000", + EnvAPIKey: "", + }, + expected: "simply: some credentials information are missing: SIMPLY_API_KEY", + }, + { + desc: "missing credentials: all", + envVars: map[string]string{ + EnvAccountName: "", + EnvAPIKey: "", + }, + expected: "simply: some credentials information are missing: SIMPLY_ACCOUNT_NAME,SIMPLY_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 + accountName string + apiKey string + expected string + }{ + { + desc: "success", + accountName: "S000000", + apiKey: "secret", + }, + { + desc: "missing account name", + apiKey: "secret", + expected: "simply: missing credentials: account name", + }, + { + desc: "missing api key", + accountName: "S000000", + expected: "simply: missing credentials: api key", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.AccountName = test.accountName + 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) +}