diff --git a/README.md b/README.md index 5170d1a3..471894bd 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [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/) | [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/) | [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/) | | | | +| [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 8c40d784..20b088d9 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -95,6 +95,7 @@ func allDNSCodes() string { "vinyldns", "vscale", "vultr", + "wedos", "yandex", "zoneee", "zonomi", @@ -1830,6 +1831,27 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/vultr`) + case "wedos": + // generated from: providers/dns/wedos/wedos.toml + ew.writeln(`Configuration for WEDOS.`) + ew.writeln(`Code: 'wedos'`) + ew.writeln(`Since: 'v4.4.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "WEDOS_USERNAME": Username is the same as for the admin account`) + ew.writeln(` - "WEDOS_WAPI_PASSWORD": Password needs to be generated and IP allowed in the admin interface`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "WEDOS_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "WEDOS_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "WEDOS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "WEDOS_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/wedos`) + case "yandex": // generated from: providers/dns/yandex/yandex.toml ew.writeln(`Configuration for Yandex.`) diff --git a/docs/content/dns/zz_gen_wedos.md b/docs/content/dns/zz_gen_wedos.md new file mode 100644 index 00000000..929f2b92 --- /dev/null +++ b/docs/content/dns/zz_gen_wedos.md @@ -0,0 +1,64 @@ +--- +title: "WEDOS" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: wedos +--- + + + + + +Since: v4.4.0 + +Configuration for [WEDOS](https://www.wedos.com). + + + + +- Code: `wedos` + +Here is an example bash command using the WEDOS provider: + +```bash +WEDOS_USERNAME=xxxxxxxx \ +WEDOS_WAPI_PASSWORD=xxxxxxxx \ +lego -email myemail@example.com --dns wedos --domains my.example.org -run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `WEDOS_USERNAME` | Username is the same as for the admin account | +| `WEDOS_WAPI_PASSWORD` | Password needs to be generated and IP allowed in the admin interface | + +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 | +|--------------------------------|-------------| +| `WEDOS_HTTP_TIMEOUT` | API request timeout | +| `WEDOS_POLLING_INTERVAL` | Time between DNS propagation check | +| `WEDOS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `WEDOS_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://kb.wedos.com/en/kategorie/wapi-api-interface/wdns-en/) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index c1cff2c6..36ebfd09 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -86,6 +86,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/vinyldns" "github.com/go-acme/lego/v4/providers/dns/vscale" "github.com/go-acme/lego/v4/providers/dns/vultr" + "github.com/go-acme/lego/v4/providers/dns/wedos" "github.com/go-acme/lego/v4/providers/dns/yandex" "github.com/go-acme/lego/v4/providers/dns/zoneee" "github.com/go-acme/lego/v4/providers/dns/zonomi" @@ -258,6 +259,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return vinyldns.NewDNSProvider() case "vscale": return vscale.NewDNSProvider() + case "wedos": + return wedos.NewDNSProvider() case "yandex": return yandex.NewDNSProvider() case "zoneee": diff --git a/providers/dns/wedos/internal/client.go b/providers/dns/wedos/internal/client.go new file mode 100644 index 00000000..be18c728 --- /dev/null +++ b/providers/dns/wedos/internal/client.go @@ -0,0 +1,215 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" +) + +const baseURL = "https://api.wedos.com/wapi/json" + +const codeOk = 1000 + +const ( + commandPing = "ping" + commandDNSDomainCommit = "dns-domain-commit" + commandDNSRowsList = "dns-rows-list" + commandDNSRowDelete = "dns-row-delete" + commandDNSRowAdd = "dns-row-add" + commandDNSRowUpdate = "dns-row-update" +) + +type ResponsePayload struct { + Code int `json:"code,omitempty"` + Result string `json:"result,omitempty"` + Timestamp int `json:"timestamp,omitempty"` + SvTRID string `json:"svTRID,omitempty"` + Command string `json:"command,omitempty"` + Data json.RawMessage `json:"data"` + DNSRowsList []DNSRow +} + +type DNSRow struct { + ID string `json:"ID,omitempty"` + Domain string `json:"domain,omitempty"` + Name string `json:"name,omitempty"` + TTL json.Number `json:"ttl,omitempty" type:"integer"` + Type string `json:"rdtype,omitempty"` + Data string `json:"rdata"` +} + +type APIRequest struct { + User string `json:"user,omitempty"` + Auth string `json:"auth,omitempty"` + Command string `json:"command,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +type Client struct { + username string + password string + baseURL string + HTTPClient *http.Client +} + +func NewClient(username string, password string) *Client { + return &Client{ + username: username, + password: password, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +// GetRecords lists all the records in the zone. +// https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-rows-list/ +func (c *Client) GetRecords(ctx context.Context, zone string) ([]DNSRow, error) { + payload := map[string]interface{}{ + "domain": dns01.UnFqdn(zone), + } + + resp, err := c.do(ctx, commandDNSRowsList, payload) + if err != nil { + return nil, err + } + + arrayWrapper := struct { + Rows []DNSRow `json:"row"` + }{} + + err = json.Unmarshal(resp.Data, &arrayWrapper) + if err != nil { + return nil, err + } + + return arrayWrapper.Rows, err +} + +// AddRecord adds a record in the zone, either by updating existing records or creating new ones. +// https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-add-row/ +// https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-row-update/ +func (c *Client) AddRecord(ctx context.Context, zone string, record DNSRow) error { + payload := DNSRow{ + Domain: dns01.UnFqdn(zone), + TTL: record.TTL, + Type: record.Type, + Data: record.Data, + } + + cmd := commandDNSRowAdd + if record.ID == "" { + payload.Name = record.Name + } else { + cmd = commandDNSRowUpdate + payload.ID = record.ID + } + + _, err := c.do(ctx, cmd, payload) + if err != nil { + return err + } + + return nil +} + +// DeleteRecord deletes a record from the zone. +// If a record does not have an ID, it will be looked up. +// https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-row-delete/ +func (c *Client) DeleteRecord(ctx context.Context, zone string, recordID string) error { + payload := DNSRow{ + Domain: dns01.UnFqdn(zone), + ID: recordID, + } + + _, err := c.do(ctx, commandDNSRowDelete, payload) + if err != nil { + return err + } + + return nil +} + +// Commit not really required, all changes will be auto-committed after 5 minutes. +// https://kb.wedos.com/en/wapi-api-interface/wapi-command-dns-domain-commit/ +func (c *Client) Commit(ctx context.Context, zone string) error { + payload := map[string]interface{}{ + "name": dns01.UnFqdn(zone), + } + + _, err := c.do(ctx, commandDNSDomainCommit, payload) + if err != nil { + return err + } + + return nil +} + +func (c *Client) Ping(ctx context.Context) error { + _, err := c.do(ctx, commandPing, nil) + if err != nil { + return err + } + + return nil +} + +func (c *Client) do(ctx context.Context, command string, payload interface{}) (*ResponsePayload, error) { + requestObject := map[string]interface{}{ + "request": APIRequest{ + User: c.username, + Auth: authToken(c.username, c.password), + Command: command, + Data: payload, + }, + } + + jsonBytes, err := json.Marshal(requestObject) + if err != nil { + return nil, err + } + + form := url.Values{} + form.Add("request", string(jsonBytes)) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("API error, status code: %d", resp.StatusCode) + } + + responseWrapper := struct { + Response ResponsePayload `json:"response"` + }{} + + err = json.Unmarshal(body, &responseWrapper) + if err != nil { + return nil, err + } + + if responseWrapper.Response.Code != codeOk { + return nil, fmt.Errorf("wedos responded with error code %d = %s", responseWrapper.Response.Code, responseWrapper.Response.Result) + } + + return &responseWrapper.Response, err +} diff --git a/providers/dns/wedos/internal/client_test.go b/providers/dns/wedos/internal/client_test.go new file mode 100644 index 00000000..a56f879c --- /dev/null +++ b/providers/dns/wedos/internal/client_test.go @@ -0,0 +1,149 @@ +package internal + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupNew(t *testing.T, expectedForm string, filename string) *Client { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { + err := req.ParseForm() + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + exp := regexp.MustCompile(`"auth":"\w+",`) + + form := req.PostForm.Get("request") + form = exp.ReplaceAllString(form, `"auth":"xxx",`) + + if form != expectedForm { + t.Logf("invalid form data: %s", req.PostForm.Get("request")) + http.Error(rw, fmt.Sprintf("invalid form data: %s", req.PostForm.Get("request")), http.StatusBadRequest) + return + } + + data, err := ioutil.ReadFile(fmt.Sprintf("./fixtures/%s.json", filename)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + rw.Header().Set("Content-Type", "application/json") + _, _ = rw.Write(data) + }) + + client := NewClient("user", "secret") + client.baseURL = server.URL + + return client +} + +func TestClient_GetRecords(t *testing.T) { + expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-rows-list","data":{"domain":"example.com"}}}` + client := setupNew(t, expectedForm, commandDNSRowsList) + + records, err := client.GetRecords(context.Background(), "example.com.") + require.NoError(t, err) + + assert.Len(t, records, 4) + + expected := []DNSRow{ + { + ID: "911", + TTL: "1800", + Type: "A", + Data: "1.2.3.4", + }, + { + ID: "913", + TTL: "1800", + Type: "MX", + Data: "1 mail1.wedos.net", + }, + { + ID: "914", + TTL: "1800", + Type: "MX", + Data: "10 mailbackup.wedos.net", + }, + { + ID: "912", + Name: "*", + TTL: "1800", + Type: "A", + Data: "1.2.3.4", + }, + } + + assert.Equal(t, expected, records) +} + +func TestClient_AddRecord(t *testing.T) { + expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-row-add","data":{"domain":"example.com","name":"foo","ttl":1800,"rdtype":"TXT","rdata":"foobar"}}}` + + client := setupNew(t, expectedForm, commandDNSRowAdd) + + record := DNSRow{ + ID: "", + Domain: "example.com", + Name: "foo", + TTL: "1800", + Type: "TXT", + Data: "foobar", + } + + err := client.AddRecord(context.Background(), "example.com.", record) + require.NoError(t, err) +} + +func TestClient_AddRecord_update(t *testing.T) { + expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-row-update","data":{"ID":"1","domain":"example.com","ttl":1800,"rdtype":"TXT","rdata":"foobar"}}}` + + client := setupNew(t, expectedForm, commandDNSRowUpdate) + + record := DNSRow{ + ID: "1", + Domain: "example.com", + Name: "foo", + TTL: "1800", + Type: "TXT", + Data: "foobar", + } + + err := client.AddRecord(context.Background(), "example.com.", record) + require.NoError(t, err) +} + +func TestClient_DeleteRecord(t *testing.T) { + expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-row-delete","data":{"ID":"1","domain":"example.com","rdata":""}}}` + + client := setupNew(t, expectedForm, commandDNSRowDelete) + + err := client.DeleteRecord(context.Background(), "example.com.", "1") + require.NoError(t, err) +} + +func TestClient_Commit(t *testing.T) { + expectedForm := `{"request":{"user":"user","auth":"xxx","command":"dns-domain-commit","data":{"name":"example.com"}}}` + + client := setupNew(t, expectedForm, commandDNSDomainCommit) + + err := client.Commit(context.Background(), "example.com.") + require.NoError(t, err) +} diff --git a/providers/dns/wedos/internal/fixtures/dns-domain-commit.json b/providers/dns/wedos/internal/fixtures/dns-domain-commit.json new file mode 100644 index 00000000..0b973379 --- /dev/null +++ b/providers/dns/wedos/internal/fixtures/dns-domain-commit.json @@ -0,0 +1,9 @@ +{ + "response": { + "code": 1000, + "result": "OK", + "timestamp": 1291192534, + "svTRID": "1291192534.6326.32542.1", + "command": "dns-domain-commit" + } +} diff --git a/providers/dns/wedos/internal/fixtures/dns-row-add.json b/providers/dns/wedos/internal/fixtures/dns-row-add.json new file mode 100644 index 00000000..66f5ffd0 --- /dev/null +++ b/providers/dns/wedos/internal/fixtures/dns-row-add.json @@ -0,0 +1,9 @@ +{ + "response": { + "code": 1000, + "result": "OK", + "timestamp": 1291210501, + "svTRID": "1291210501.7672.19698.1", + "command": "dns-row-add" + } +} diff --git a/providers/dns/wedos/internal/fixtures/dns-row-delete.json b/providers/dns/wedos/internal/fixtures/dns-row-delete.json new file mode 100644 index 00000000..49514ae3 --- /dev/null +++ b/providers/dns/wedos/internal/fixtures/dns-row-delete.json @@ -0,0 +1,9 @@ +{ + "response": { + "code": 1000, + "result": "OK", + "timestamp": 1291370821, + "svTRID": "1291370821.1702.7371.1", + "command": "dns-row-delete" + } +} diff --git a/providers/dns/wedos/internal/fixtures/dns-row-update.json b/providers/dns/wedos/internal/fixtures/dns-row-update.json new file mode 100644 index 00000000..99108eb6 --- /dev/null +++ b/providers/dns/wedos/internal/fixtures/dns-row-update.json @@ -0,0 +1,9 @@ +{ + "response": { + "code": 1000, + "result": "OK", + "timestamp": 1291370821, + "svTRID": "1291370821.1702.7371.1", + "command": "dns-row-update" + } +} diff --git a/providers/dns/wedos/internal/fixtures/dns-rows-list.json b/providers/dns/wedos/internal/fixtures/dns-rows-list.json new file mode 100644 index 00000000..17d85433 --- /dev/null +++ b/providers/dns/wedos/internal/fixtures/dns-rows-list.json @@ -0,0 +1,49 @@ +{ + "response": { + "code": 1000, + "result": "OK", + "timestamp": 1291194425, + "svTRID": "1291194425.9562.9881.1", + "command": "dns-rows-list", + "data": { + "row": [ + { + "ID": "911", + "name": "", + "ttl": "1800", + "rdtype": "A", + "rdata": "1.2.3.4", + "changed_date": "2010-12-01 09:54:41", + "author_comment": "" + }, + { + "ID": "913", + "name": "", + "ttl": "1800", + "rdtype": "MX", + "rdata": "1 mail1.wedos.net", + "changed_date": "2010-12-01 09:54:54", + "author_comment": "" + }, + { + "ID": "914", + "name": "", + "ttl": "1800", + "rdtype": "MX", + "rdata": "10 mailbackup.wedos.net", + "changed_date": "2010-12-01 09:55:07", + "author_comment": "" + }, + { + "ID": "912", + "name": "*", + "ttl": "1800", + "rdtype": "A", + "rdata": "1.2.3.4", + "changed_date": "2010-12-01 09:54:46", + "author_comment": "" + } + ] + } + } +} diff --git a/providers/dns/wedos/internal/token.go b/providers/dns/wedos/internal/token.go new file mode 100644 index 00000000..7655092e --- /dev/null +++ b/providers/dns/wedos/internal/token.go @@ -0,0 +1,73 @@ +package internal + +import ( + "crypto/sha1" + "fmt" + "io" + "time" +) + +func authToken(userName string, wapiPass string) string { + return sha1string(userName + sha1string(wapiPass) + czechHourString()) +} + +func sha1string(txt string) string { + h := sha1.New() + _, _ = io.WriteString(h, txt) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func czechHourString() string { + return formatHour(czechHour()) +} + +func czechHour() int { + tryZones := []string{"Europe/Prague", "Europe/Paris", "CET"} + + for _, zoneName := range tryZones { + loc, err := time.LoadLocation(zoneName) + if err == nil { + return time.Now().In(loc).Hour() + } + } + + // hopefully this will never be used + // this is fallback for containers without tzdata installed + return utcToCet(time.Now().UTC()).Hour() +} + +func utcToCet(utc time.Time) time.Time { + // https://en.wikipedia.org/wiki/Central_European_Time + // As of 2011, all member states of the European Union observe summer time (daylight saving time), + // from the last Sunday in March to the last Sunday in October. + // States within the CET area switch to Central European Summer Time (CEST -- UTC+02:00) for the summer.[1] + utcMonth := utc.Month() + if utcMonth < time.March || utcMonth > time.October { + return utc.Add(time.Hour) + } + if utcMonth > time.March && utcMonth < time.October { + return utc.Add(time.Hour * 2) + } + + dayOff := 0 + breaking := time.Date(utc.Year(), utcMonth+1, dayOff, 1, 0, 0, 0, time.UTC) + for { + if breaking.Weekday() == time.Sunday { + break + } + dayOff-- + breaking = time.Date(utc.Year(), utcMonth+1, dayOff, 1, 0, 0, 0, time.UTC) + if dayOff < -7 { + panic("safety exit to avoid infinite loop") + } + } + + if (utcMonth == time.March && utc.Before(breaking)) || (utcMonth == time.October && utc.After(breaking)) { + return utc.Add(time.Hour) + } + return utc.Add(time.Hour * 2) +} + +func formatHour(hour int) string { + return fmt.Sprintf("%02d", hour) +} diff --git a/providers/dns/wedos/wedos.go b/providers/dns/wedos/wedos.go new file mode 100644 index 00000000..b5cff3f5 --- /dev/null +++ b/providers/dns/wedos/wedos.go @@ -0,0 +1,186 @@ +package wedos + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "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/wedos/internal" +) + +// Environment variables names. +const ( + envNamespace = "WEDOS_" + + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "WAPI_PASSWORD" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +const minTTL = 5 * 60 // 5 minutes + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Username string + Password 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{ + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), + TTL: env.GetOrDefaultInt(EnvTTL, minTTL), + 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. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword) + if err != nil { + return nil, fmt.Errorf("wedos: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("wedos: the configuration of the DNS provider is nil") + } + + if config.Username == "" || config.Password == "" { + return nil, errors.New("wedos: some credentials information are missing") + } + + if config.TTL < minTTL { + return nil, fmt.Errorf("wedos: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) + } + + client := internal.NewClient(config.Username, config.Password) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{config: config, client: client}, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS 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 the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() + + fqdn, value := dns01.GetRecord(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("wedos: could not determine zone for domain %q: %w", domain, err) + } + + subDomain := strings.TrimSuffix(fqdn, authZone) + + record := internal.DNSRow{ + Name: subDomain, + TTL: json.Number(strconv.Itoa(d.config.TTL)), + Type: "TXT", + Data: value, + } + + records, err := d.client.GetRecords(ctx, authZone) + if err != nil { + return fmt.Errorf("wedos: could not get records for domain %q: %w", domain, err) + } + + for _, candidate := range records { + if candidate.Type == "TXT" && candidate.Name == subDomain && candidate.Data == value { + record.ID = candidate.ID + break + } + } + + err = d.client.AddRecord(ctx, authZone, record) + if err != nil { + return fmt.Errorf("wedos: could not add TXT record for domain %q: %w", domain, err) + } + + err = d.client.Commit(ctx, authZone) + if err != nil { + return fmt.Errorf("wedos: could not commit TXT record for domain %q: %w", domain, err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() + + fqdn, value := dns01.GetRecord(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("wedos: could not determine zone for domain %q: %w", domain, err) + } + + subDomain := strings.TrimSuffix(fqdn, authZone) + + records, err := d.client.GetRecords(ctx, authZone) + if err != nil { + return fmt.Errorf("wedos: could not get records for domain %q: %w", domain, err) + } + + for _, candidate := range records { + if candidate.Type != "TXT" || candidate.Name != subDomain || candidate.Data != value { + continue + } + + err = d.client.DeleteRecord(ctx, authZone, candidate.ID) + if err != nil { + return fmt.Errorf("wedos: could not remove TXT record for domain %q: %w", domain, err) + } + + err = d.client.Commit(ctx, authZone) + if err != nil { + return fmt.Errorf("wedos: could not commit TXT record for domain %q: %w", domain, err) + } + + return nil + } + + return nil +} diff --git a/providers/dns/wedos/wedos.toml b/providers/dns/wedos/wedos.toml new file mode 100644 index 00000000..b8e1f870 --- /dev/null +++ b/providers/dns/wedos/wedos.toml @@ -0,0 +1,24 @@ +Name = "WEDOS" +Description = '''''' +URL = "https://www.wedos.com" +Code = "wedos" +Since = "v4.4.0" + +Example = ''' +WEDOS_USERNAME=xxxxxxxx \ +WEDOS_WAPI_PASSWORD=xxxxxxxx \ +lego -email myemail@example.com --dns wedos --domains my.example.org -run +''' + +[Configuration] + [Configuration.Credentials] + WEDOS_USERNAME = "Username is the same as for the admin account" + WEDOS_WAPI_PASSWORD = "Password needs to be generated and IP allowed in the admin interface" + [Configuration.Additional] + WEDOS_POLLING_INTERVAL = "Time between DNS propagation check" + WEDOS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + WEDOS_HTTP_TIMEOUT = "API request timeout" + WEDOS_TTL = "The TTL of the TXT record used for the DNS challenge" + +[Links] + API = "https://kb.wedos.com/en/kategorie/wapi-api-interface/wdns-en/" diff --git a/providers/dns/wedos/wedos_test.go b/providers/dns/wedos/wedos_test.go new file mode 100644 index 00000000..9363002b --- /dev/null +++ b/providers/dns/wedos/wedos_test.go @@ -0,0 +1,141 @@ +package wedos + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvUsername, EnvPassword). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvUsername: "admin@example.com", + EnvPassword: "secret", + }, + }, + { + desc: "missing credentials: username", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "secret", + }, + expected: "wedos: some credentials information are missing: WEDOS_USERNAME", + }, + { + desc: "missing credentials: password", + envVars: map[string]string{ + EnvUsername: "admin@example.com", + EnvPassword: "", + }, + expected: "wedos: some credentials information are missing: WEDOS_WAPI_PASSWORD", + }, + { + desc: "missing credentials: all", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "", + }, + expected: "wedos: some credentials information are missing: WEDOS_USERNAME,WEDOS_WAPI_PASSWORD", + }, + } + + 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) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + username string + password string + expected string + }{ + { + desc: "success", + username: "admin@example.com", + password: "secret", + }, + { + desc: "missing username", + password: "secret", + expected: "wedos: some credentials information are missing", + }, + { + desc: "missing WAPI password", + username: "admin@example.com", + expected: "wedos: some credentials information are missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Username = test.username + config.Password = test.password + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } 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) +}