forked from TrueCloudLab/lego
Add DNS provider for Servercow. (#1056)
This commit is contained in:
parent
5cdc0002e9
commit
14329c03df
12 changed files with 1172 additions and 0 deletions
|
@ -12,6 +12,7 @@ matrix:
|
||||||
- go: 1.x
|
- go: 1.x
|
||||||
- go: tip
|
- go: tip
|
||||||
allow_failures:
|
allow_failures:
|
||||||
|
- go: 1.x # FIXME currently golangci-lint doesn't work with go1.14
|
||||||
- go: tip
|
- go: tip
|
||||||
|
|
||||||
go_import_path: github.com/go-acme/lego
|
go_import_path: github.com/go-acme/lego
|
||||||
|
|
|
@ -70,6 +70,7 @@ func allDNSCodes() string {
|
||||||
"sakuracloud",
|
"sakuracloud",
|
||||||
"scaleway",
|
"scaleway",
|
||||||
"selectel",
|
"selectel",
|
||||||
|
"servercow",
|
||||||
"stackpath",
|
"stackpath",
|
||||||
"transip",
|
"transip",
|
||||||
"vegadns",
|
"vegadns",
|
||||||
|
@ -1281,6 +1282,27 @@ func displayDNSHelp(name string) error {
|
||||||
ew.writeln()
|
ew.writeln()
|
||||||
ew.writeln(`More information: https://go-acme.github.io/lego/dns/selectel`)
|
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":
|
case "stackpath":
|
||||||
// generated from: providers/dns/stackpath/stackpath.toml
|
// generated from: providers/dns/stackpath/stackpath.toml
|
||||||
ew.writeln(`Configuration for Stackpath.`)
|
ew.writeln(`Configuration for Stackpath.`)
|
||||||
|
|
64
docs/content/dns/zz_gen_servercow.md
Normal file
64
docs/content/dns/zz_gen_servercow.md
Normal 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. -->
|
|
@ -61,6 +61,7 @@ import (
|
||||||
"github.com/go-acme/lego/v3/providers/dns/sakuracloud"
|
"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/scaleway"
|
||||||
"github.com/go-acme/lego/v3/providers/dns/selectel"
|
"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/stackpath"
|
||||||
"github.com/go-acme/lego/v3/providers/dns/transip"
|
"github.com/go-acme/lego/v3/providers/dns/transip"
|
||||||
"github.com/go-acme/lego/v3/providers/dns/vegadns"
|
"github.com/go-acme/lego/v3/providers/dns/vegadns"
|
||||||
|
@ -187,6 +188,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
|
||||||
return scaleway.NewDNSProvider()
|
return scaleway.NewDNSProvider()
|
||||||
case "selectel":
|
case "selectel":
|
||||||
return selectel.NewDNSProvider()
|
return selectel.NewDNSProvider()
|
||||||
|
case "servercow":
|
||||||
|
return servercow.NewDNSProvider()
|
||||||
case "stackpath":
|
case "stackpath":
|
||||||
return stackpath.NewDNSProvider()
|
return stackpath.NewDNSProvider()
|
||||||
case "transip":
|
case "transip":
|
||||||
|
|
173
providers/dns/servercow/internal/client.go
Normal file
173
providers/dns/servercow/internal/client.go
Normal 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))
|
||||||
|
}
|
223
providers/dns/servercow/internal/client_test.go
Normal file
223
providers/dns/servercow/internal/client_test.go
Normal 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)
|
||||||
|
}
|
125
providers/dns/servercow/internal/fixtures/records-01.json
Normal file
125
providers/dns/servercow/internal/fixtures/records-01.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
58
providers/dns/servercow/internal/model.go
Normal file
58
providers/dns/servercow/internal/model.go
Normal 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
|
||||||
|
}
|
106
providers/dns/servercow/internal/model_test.go
Normal file
106
providers/dns/servercow/internal/model_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
223
providers/dns/servercow/servercow.go
Normal file
223
providers/dns/servercow/servercow.go
Normal 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]
|
||||||
|
}
|
24
providers/dns/servercow/servercow.toml
Normal file
24
providers/dns/servercow/servercow.toml
Normal 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/"
|
150
providers/dns/servercow/servercow_test.go
Normal file
150
providers/dns/servercow/servercow_test.go
Normal 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)
|
||||||
|
}
|
Loading…
Reference in a new issue