Add DNS provider for Servercow. (#1056)

This commit is contained in:
Ludovic Fernandez 2020-02-25 21:41:39 +01:00 committed by GitHub
parent 5cdc0002e9
commit 14329c03df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1172 additions and 0 deletions

View file

@ -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

View file

@ -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.`)

View file

@ -0,0 +1,64 @@
---
title: "Servercow"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: servercow
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/servercow/servercow.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Since: v3.4.0
Configuration for [Servercow](https://servercow.de/).
<!--more-->
- 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/)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/servercow/servercow.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

View file

@ -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":

View file

@ -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))
}

View file

@ -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)
}

View file

@ -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"
}
]

View file

@ -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
}

View file

@ -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)
})
}
}

View file

@ -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]
}

View file

@ -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/"

View file

@ -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)
}