diff --git a/.travis.yml b/.travis.yml index 458e0f7e..d0408b4f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,7 @@ matrix: - go: 1.x - go: tip allow_failures: + - go: 1.x # FIXME currently golangci-lint doesn't work with go1.14 - go: tip go_import_path: github.com/go-acme/lego diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 63108e3e..e25f87c7 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -70,6 +70,7 @@ func allDNSCodes() string { "sakuracloud", "scaleway", "selectel", + "servercow", "stackpath", "transip", "vegadns", @@ -1281,6 +1282,27 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/selectel`) + case "servercow": + // generated from: providers/dns/servercow/servercow.toml + ew.writeln(`Configuration for Servercow.`) + ew.writeln(`Code: 'servercow'`) + ew.writeln(`Since: 'v3.4.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "SERVERCOW_PASSWORD": API password`) + ew.writeln(` - "SERVERCOW_USERNAME": API username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "SERVERCOW_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "SERVERCOW_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "SERVERCOW_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "SERVERCOW_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/servercow`) + case "stackpath": // generated from: providers/dns/stackpath/stackpath.toml ew.writeln(`Configuration for Stackpath.`) diff --git a/docs/content/dns/zz_gen_servercow.md b/docs/content/dns/zz_gen_servercow.md new file mode 100644 index 00000000..4502534d --- /dev/null +++ b/docs/content/dns/zz_gen_servercow.md @@ -0,0 +1,64 @@ +--- +title: "Servercow" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: servercow +--- + + + + + +Since: v3.4.0 + +Configuration for [Servercow](https://servercow.de/). + + + + +- Code: `servercow` + +Here is an example bash command using the Servercow provider: + +```bash +SERVERCOW_USERNAME=xxxxxxxx \ +SERVERCOW_PASSWORD=xxxxxxxx \ +lego --dns servercow --domains my.domain.com --email my@email.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `SERVERCOW_PASSWORD` | API password | +| `SERVERCOW_USERNAME` | API username | + +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 | +|--------------------------------|-------------| +| `SERVERCOW_HTTP_TIMEOUT` | API request timeout | +| `SERVERCOW_POLLING_INTERVAL` | Time between DNS propagation check | +| `SERVERCOW_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `SERVERCOW_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://cp.servercow.de/client/plugin/support_manager/knowledgebase/view/34/dns-api-v1/7/) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index e7d4ac90..a095c3c2 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -61,6 +61,7 @@ import ( "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/servercow" "github.com/go-acme/lego/v3/providers/dns/stackpath" "github.com/go-acme/lego/v3/providers/dns/transip" "github.com/go-acme/lego/v3/providers/dns/vegadns" @@ -187,6 +188,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return scaleway.NewDNSProvider() case "selectel": return selectel.NewDNSProvider() + case "servercow": + return servercow.NewDNSProvider() case "stackpath": return stackpath.NewDNSProvider() case "transip": diff --git a/providers/dns/servercow/internal/client.go b/providers/dns/servercow/internal/client.go new file mode 100644 index 00000000..963d9d99 --- /dev/null +++ b/providers/dns/servercow/internal/client.go @@ -0,0 +1,173 @@ +package internal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" +) + +const baseAPIURL = "https://api.servercow.de/dns/v1/domains" + +// Client the Servercow client. +type Client struct { + BaseURL string + HTTPClient *http.Client + + username string + password string +} + +// NewClient Creates a Servercow client. +func NewClient(username, password string) *Client { + return &Client{ + HTTPClient: http.DefaultClient, + BaseURL: baseAPIURL, + username: username, + password: password, + } +} + +// GetRecords from API. +func (c *Client) GetRecords(domain string) ([]Record, error) { + req, err := c.createRequest(http.MethodGet, domain, nil) + if err != nil { + return nil, err + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + // Note the API always return 200 even if the authentication failed. + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("error: status code %d", resp.StatusCode) + } + + raw, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read body: %w", err) + } + + var records []Record + err = unmarshal(raw, &records) + if err != nil { + return nil, err + } + + return records, nil +} + +// CreateUpdateRecord creates or updates a record. +func (c *Client) CreateUpdateRecord(domain string, data Record) (*Message, error) { + req, err := c.createRequest(http.MethodPost, domain, &data) + if err != nil { + return nil, err + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + // Note the API always return 200 even if the authentication failed. + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("error: status code %d", resp.StatusCode) + } + + raw, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read body: %w", err) + } + + var msg Message + err = json.Unmarshal(raw, &msg) + if err != nil { + return nil, err + } + + if msg.ErrorMsg != "" { + return nil, msg + } + + return &msg, nil +} + +// DeleteRecord deletes a record. +func (c *Client) DeleteRecord(domain string, data Record) (*Message, error) { + req, err := c.createRequest(http.MethodDelete, domain, &data) + if err != nil { + return nil, err + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + // Note the API always return 200 even if the authentication failed. + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("error: status code %d", resp.StatusCode) + } + + raw, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read body: %w", err) + } + + var msg Message + err = json.Unmarshal(raw, &msg) + if err != nil { + return nil, fmt.Errorf("unmarshaling %T error: %w: %s", msg, err, string(raw)) + } + + if msg.ErrorMsg != "" { + return nil, msg + } + + return &msg, nil +} + +func (c *Client) createRequest(method, domain string, payload *Record) (*http.Request, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(method, c.BaseURL+"/"+domain, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("X-Auth-Username", c.username) + req.Header.Set("X-Auth-Password", c.password) + req.Header.Set("Content-Type", "application/json") + + return req, nil +} + +func unmarshal(raw []byte, v interface{}) error { + err := json.Unmarshal(raw, v) + if err == nil { + return nil + } + + var e *json.UnmarshalTypeError + if errors.As(err, &e) { + var apiError Message + errU := json.Unmarshal(raw, &apiError) + if errU != nil { + return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw)) + } + + return apiError + } + + return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw)) +} diff --git a/providers/dns/servercow/internal/client_test.go b/providers/dns/servercow/internal/client_test.go new file mode 100644 index 00000000..1745cf67 --- /dev/null +++ b/providers/dns/servercow/internal/client_test.go @@ -0,0 +1,223 @@ +package internal + +import ( + "encoding/json" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupAPIMock() (*Client, *http.ServeMux, func()) { + handler := http.NewServeMux() + svr := httptest.NewServer(handler) + + client := NewClient("", "") + client.BaseURL = svr.URL + + return client, handler, svr.Close +} + +func TestClient_GetRecords(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + file, err := os.Open("./fixtures/records-01.json") + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + records, err := client.GetRecords("lego.wtf") + require.NoError(t, err) + + recordsJSON, err := json.Marshal(records) + require.NoError(t, err) + + expectedContent, err := ioutil.ReadFile("./fixtures/records-01.json") + require.NoError(t, err) + + assert.JSONEq(t, string(expectedContent), string(recordsJSON)) +} + +func TestClient_GetRecords_error(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + err := json.NewEncoder(rw).Encode(Message{ErrorMsg: "authentication failed"}) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + records, err := client.GetRecords("lego.wtf") + require.Error(t, err) + + assert.Nil(t, records) +} + +func TestClient_CreateUpdateRecord(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + content, err := ioutil.ReadAll(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + expectedRequest := `{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":["aaa","bbb"]}` + + if !assert.JSONEq(t, expectedRequest, string(content)) { + http.Error(rw, "invalid content", http.StatusBadRequest) + return + } + + err = json.NewEncoder(rw).Encode(Message{Message: "ok"}) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + record := Record{ + Name: "_acme-challenge.www", + Type: "TXT", + TTL: 30, + Content: Value{"aaa", "bbb"}, + } + + msg, err := client.CreateUpdateRecord("lego.wtf", record) + require.NoError(t, err) + + expected := &Message{Message: "ok"} + assert.Equal(t, expected, msg) +} + +func TestClient_CreateUpdateRecord_error(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + err := json.NewEncoder(rw).Encode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"}) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + record := Record{ + Name: "_acme-challenge.www", + } + + msg, err := client.CreateUpdateRecord("lego.wtf", record) + require.Error(t, err) + + assert.Nil(t, msg) +} + +func TestClient_DeleteRecord(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodDelete { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + content, err := ioutil.ReadAll(req.Body) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + expectedRequest := `{"name":"_acme-challenge.www","type":"TXT"}` + + if !assert.JSONEq(t, expectedRequest, string(content)) { + http.Error(rw, "invalid content", http.StatusBadRequest) + return + } + + err = json.NewEncoder(rw).Encode(Message{Message: "ok"}) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + record := Record{ + Name: "_acme-challenge.www", + Type: "TXT", + } + + msg, err := client.DeleteRecord("lego.wtf", record) + require.NoError(t, err) + + expected := &Message{Message: "ok"} + assert.Equal(t, expected, msg) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/lego.wtf", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodDelete { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + err := json.NewEncoder(rw).Encode(Message{ErrorMsg: "parameter type must be cname, txt, tlsa, caa, a or aaaa"}) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + record := Record{ + Name: "_acme-challenge.www", + } + + msg, err := client.DeleteRecord("lego.wtf", record) + require.Error(t, err) + + assert.Nil(t, msg) +} diff --git a/providers/dns/servercow/internal/fixtures/records-01.json b/providers/dns/servercow/internal/fixtures/records-01.json new file mode 100644 index 00000000..5e5afbb2 --- /dev/null +++ b/providers/dns/servercow/internal/fixtures/records-01.json @@ -0,0 +1,125 @@ +[ + { + "name": "letsencrypt", + "ttl": 120, + "type": "A", + "content": "1.1.1.1" + }, + { + "name": "diskover", + "ttl": 120, + "type": "CAA", + "content": "0 issue \"letsencrypt.org\"" + }, + { + "name": "diskover", + "ttl": 120, + "type": "AAAA", + "content": ":::::" + }, + { + "name": "diskover", + "ttl": 120, + "type": "A", + "content": "1.1.1.1" + }, + { + "name": "portainer", + "ttl": 120, + "type": "CAA", + "content": "0 issue \"letsencrypt.org\"" + }, + { + "name": "portainer", + "ttl": 120, + "type": "AAAA", + "content": ":::::" + }, + { + "name": "portainer", + "ttl": 120, + "type": "A", + "content": "1.1.1.1" + }, + { + "name": "lego", + "ttl": 120, + "type": "A", + "content": "1.1.1.1" + }, + { + "name": "traefik", + "ttl": 120, + "type": "CAA", + "content": "0 issue \"letsencrypt.org\"" + }, + { + "name": "traefik", + "ttl": 120, + "type": "AAAA", + "content": ":::::" + }, + { + "name": "traefik", + "ttl": 120, + "type": "A", + "content": "1.1.1.1" + }, + { + "name": "spaghetti", + "ttl": 120, + "type": "CAA", + "content": "0 issue \"letsencrypt.org\"" + }, + { + "name": "spaghetti", + "ttl": 120, + "type": "AAAA", + "content": ":::::" + }, + { + "name": "spaghetti", + "ttl": 120, + "type": "A", + "content": "1.1.1.1" + }, + { + "name": "dragonstone", + "ttl": 120, + "type": "CAA", + "content": "0 issue \"letsencrypt.org\"" + }, + { + "name": "dragonstone", + "ttl": 120, + "type": "A", + "content": "1.1.1.1" + }, + { + "name": "_acme-challenge.sample", + "ttl": 20, + "type": "TXT", + "content": [ + "txtxtxtxtxtxtxt", + "acbdefghijklmnopqrstuvwxyz" + ] + }, + { + "name": "", + "ttl": 120, + "type": "CAA", + "content": "0 issue \"letsencrypt.org\"" + }, + { + "name": "", + "ttl": 120, + "type": "AAAA", + "content": ":::::" + }, + { + "name": "", + "ttl": 120, + "type": "A", + "content": "1.1.1.1" + } +] diff --git a/providers/dns/servercow/internal/model.go b/providers/dns/servercow/internal/model.go new file mode 100644 index 00000000..50804dc6 --- /dev/null +++ b/providers/dns/servercow/internal/model.go @@ -0,0 +1,58 @@ +package internal + +import "encoding/json" + +// Record is the record representation. +type Record struct { + Name string `json:"name"` + Type string `json:"type"` + TTL int `json:"ttl,omitempty"` + + Content Value `json:"content,omitempty"` +} + +// Value is the value of a record. +// Allows to handle dynamic type (string and string array) +type Value []string + +func (v Value) MarshalJSON() ([]byte, error) { + if len(v) == 0 { + return nil, nil + } + + if len(v) == 1 { + return json.Marshal(v[0]) + } + + content, err := json.Marshal([]string(v)) + if err != nil { + return nil, err + } + + return content, nil +} + +func (v *Value) UnmarshalJSON(b []byte) error { + if b[0] == '[' { + return json.Unmarshal(b, (*[]string)(v)) + } + + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + + *v = append(*v, s) + return nil +} + +// Message is the basic response representation. +// Can be an error. +type Message struct { + Message string `json:"message,omitempty"` + ErrorMsg string `json:"error,omitempty"` +} + +func (a Message) Error() string { + return a.ErrorMsg +} diff --git a/providers/dns/servercow/internal/model_test.go b/providers/dns/servercow/internal/model_test.go new file mode 100644 index 00000000..53a57dfb --- /dev/null +++ b/providers/dns/servercow/internal/model_test.go @@ -0,0 +1,106 @@ +package internal + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValue_MarshalJSON(t *testing.T) { + testCases := []struct { + desc string + record Record + expected string + }{ + { + desc: "empty content", + record: Record{ + Name: "_acme-challenge.www", + Type: "TXT", + TTL: 30, + Content: Value{}, + }, + expected: `{"name":"_acme-challenge.www","type":"TXT","ttl":30}`, + }, + { + desc: "content with a single value", + record: Record{ + Name: "_acme-challenge.www", + Type: "TXT", + TTL: 30, + Content: Value{"aaa"}, + }, + expected: `{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":"aaa"}`, + }, + { + desc: "content with multiple values", + record: Record{ + Name: "_acme-challenge.www", + Type: "TXT", + TTL: 30, + Content: Value{"aaa", "bbb", "ccc"}, + }, + expected: `{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":["aaa","bbb","ccc"]}`, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + content, err := json.Marshal(test.record) + require.NoError(t, err) + + assert.JSONEq(t, test.expected, string(content)) + }) + } +} + +func TestValue_UnmarshalJSON(t *testing.T) { + testCases := []struct { + desc string + data string + expected Record + }{ + { + desc: "empty content", + data: `{"name":"_acme-challenge.www","type":"TXT","ttl":30}`, + expected: Record{ + Name: "_acme-challenge.www", + Type: "TXT", + TTL: 30, + Content: Value(nil), + }, + }, + { + desc: "content with a single value", + data: `{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":"aaa"}`, + expected: Record{ + Name: "_acme-challenge.www", + Type: "TXT", + TTL: 30, + Content: Value{"aaa"}, + }, + }, + { + desc: "content with multiple values", + data: `{"name":"_acme-challenge.www","type":"TXT","ttl":30,"content":["aaa","bbb","ccc"]}`, + expected: Record{ + Name: "_acme-challenge.www", + Type: "TXT", + TTL: 30, + Content: Value{"aaa", "bbb", "ccc"}, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + record := Record{} + err := json.Unmarshal([]byte(test.data), &record) + require.NoError(t, err) + + assert.Equal(t, test.expected, record) + }) + } +} diff --git a/providers/dns/servercow/servercow.go b/providers/dns/servercow/servercow.go new file mode 100644 index 00000000..56b5de7d --- /dev/null +++ b/providers/dns/servercow/servercow.go @@ -0,0 +1,223 @@ +// Package servercow implements a DNS provider for solving the DNS-01 challenge using Servercow DNS. +package servercow + +import ( + "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/servercow/internal" +) + +const defaultTTL = 120 + +// 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{ + TTL: env.GetOrDefaultInt("SERVERCOW_TTL", defaultTTL), + PropagationTimeout: env.GetOrDefaultSecond("SERVERCOW_PROPAGATION_TIMEOUT", dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("SERVERCOW_POLLING_INTERVAL", dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("SERVERCOW_HTTP_TIMEOUT", 30*time.Second), + }, + } +} + +// DNSProvider implements challenge.Provider for the Servercow API. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get("SERVERCOW_USERNAME", "SERVERCOW_PASSWORD") + if err != nil { + return nil, fmt.Errorf("servercow: %w", err) + } + + config := NewDefaultConfig() + config.Username = values["SERVERCOW_USERNAME"] + config.Password = values["SERVERCOW_PASSWORD"] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Servercow. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config.Username == "" || config.Password == "" { + return nil, fmt.Errorf("servercow: incomplete credentials, missing username and/or password") + } + + if config.HTTPClient == nil { + config.HTTPClient = http.DefaultClient + } + + client := internal.NewClient(config.Username, config.Password) + 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 { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + authZone, err := getAuthZone(domain) + if err != nil { + return fmt.Errorf("servercow: %w", err) + } + + records, err := d.client.GetRecords(authZone) + if err != nil { + return fmt.Errorf("servercow: %w", err) + } + + recordName := getRecordName(fqdn, authZone) + + record := findRecords(records, recordName) + + // TXT record entry already existing + if record != nil { + if containsValue(record, value) { + return nil + } + + request := internal.Record{ + Name: record.Name, + TTL: record.TTL, + Type: record.Type, + Content: append(record.Content, value), + } + + _, err = d.client.CreateUpdateRecord(authZone, request) + if err != nil { + return fmt.Errorf("servercow: failed to update TXT records: %w", err) + } + return nil + } + + request := internal.Record{ + Type: "TXT", + Name: recordName, + TTL: d.config.TTL, + Content: internal.Value{value}, + } + + _, err = d.client.CreateUpdateRecord(authZone, request) + if err != nil { + return fmt.Errorf("servercow: failed to create TXT record %s: %w", fqdn, err) + } + + return nil +} + +// CleanUp removes the TXT record previously created. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + authZone, err := getAuthZone(domain) + if err != nil { + return fmt.Errorf("servercow: %w", err) + } + + records, err := d.client.GetRecords(authZone) + if err != nil { + return fmt.Errorf("servercow: failed to get TXT records: %w", err) + } + + recordName := getRecordName(fqdn, authZone) + + record := findRecords(records, recordName) + if record == nil { + return nil + } + + if !containsValue(record, value) { + return nil + } + + // only 1 record value, the whole record must be deleted. + if len(record.Content) == 1 { + _, err = d.client.DeleteRecord(authZone, *record) + if err != nil { + return fmt.Errorf("servercow: failed to delete TXT records: %w", err) + } + return nil + } + + request := internal.Record{ + Name: record.Name, + Type: record.Type, + TTL: record.TTL, + } + + for _, val := range record.Content { + if val != value { + request.Content = append(request.Content, val) + } + } + + _, err = d.client.CreateUpdateRecord(authZone, request) + if err != nil { + return fmt.Errorf("servercow: failed to update TXT records: %w", err) + } + + return nil +} + +func getAuthZone(domain string) (string, error) { + authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) + if err != nil { + return "", fmt.Errorf("could not find zone for domain %q: %w", domain, err) + } + + zoneName := dns01.UnFqdn(authZone) + return zoneName, nil +} + +func findRecords(records []internal.Record, name string) *internal.Record { + for _, r := range records { + if r.Type == "TXT" && r.Name == name { + return &r + } + } + + return nil +} + +func containsValue(record *internal.Record, value string) bool { + for _, val := range record.Content { + if val == value { + return true + } + } + + return false +} + +func getRecordName(fqdn, authZone string) string { + return fqdn[0 : len(fqdn)-len(authZone)-2] +} diff --git a/providers/dns/servercow/servercow.toml b/providers/dns/servercow/servercow.toml new file mode 100644 index 00000000..7a3bb558 --- /dev/null +++ b/providers/dns/servercow/servercow.toml @@ -0,0 +1,24 @@ +Name = "Servercow" +Description = '''''' +URL = "https://servercow.de/" +Code = "servercow" +Since = "v3.4.0" + +Example = ''' +SERVERCOW_USERNAME=xxxxxxxx \ +SERVERCOW_PASSWORD=xxxxxxxx \ +lego --dns servercow --domains my.domain.com --email my@email.com run +''' + +[Configuration] + [Configuration.Credentials] + SERVERCOW_USERNAME = "API username" + SERVERCOW_PASSWORD = "API password" + [Configuration.Additional] + SERVERCOW_POLLING_INTERVAL = "Time between DNS propagation check" + SERVERCOW_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + SERVERCOW_TTL = "The TTL of the TXT record used for the DNS challenge" + SERVERCOW_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://cp.servercow.de/client/plugin/support_manager/knowledgebase/view/34/dns-api-v1/7/" diff --git a/providers/dns/servercow/servercow_test.go b/providers/dns/servercow/servercow_test.go new file mode 100644 index 00000000..08746890 --- /dev/null +++ b/providers/dns/servercow/servercow_test.go @@ -0,0 +1,150 @@ +package servercow + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v3/platform/tester" + "github.com/stretchr/testify/require" +) + +var envTest = tester.NewEnvTest( + "SERVERCOW_USERNAME", + "SERVERCOW_PASSWORD"). + WithDomain("SERVERCOW_DOMAIN") + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + "SERVERCOW_USERNAME": "123", + "SERVERCOW_PASSWORD": "456", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + "SERVERCOW_USERNAME": "", + "SERVERCOW_PASSWORD": "", + }, + expected: "servercow: some credentials information are missing: SERVERCOW_USERNAME,SERVERCOW_PASSWORD", + }, + { + desc: "missing username", + envVars: map[string]string{ + "SERVERCOW_USERNAME": "", + "SERVERCOW_PASSWORD": "api_password", + }, + expected: "servercow: some credentials information are missing: SERVERCOW_USERNAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + "SERVERCOW_USERNAME": "api_username", + "SERVERCOW_PASSWORD": "", + }, + expected: "servercow: some credentials information are missing: SERVERCOW_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 len(test.expected) == 0 { + 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 + expected string + username string + password string + }{ + { + desc: "success", + username: "api_username", + password: "api_password", + }, + { + desc: "missing credentials", + expected: "servercow: incomplete credentials, missing username and/or password", + }, + { + desc: "missing api key", + username: "", + password: "api_password", + expected: "servercow: incomplete credentials, missing username and/or password", + }, + { + desc: "missing secret key", + username: "api_username", + password: "", + expected: "servercow: incomplete credentials, missing username and/or password", + }, + } + + 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 len(test.expected) == 0 { + 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) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +}