mirror of
https://github.com/go-acme/lego.git
synced 2024-11-25 00:56:20 +02:00
Add DNS provider for Servercow. (#1056)
This commit is contained in:
parent
5cdc0002e9
commit
14329c03df
@ -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
|
||||
|
@ -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.`)
|
||||
|
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/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":
|
||||
|
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 New Issue
Block a user