Add DNS provider for UKFast SafeDNS (#1545)

Co-authored-by: Fernandez Ludovic <ldez@users.noreply.github.com>
This commit is contained in:
Dane 2021-12-20 14:15:49 +00:00 committed by GitHub
parent f7c287e520
commit 0324783e09
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 660 additions and 3 deletions

View file

@ -68,9 +68,9 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
| [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) |
| [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) |
| [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) |
| [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [UKFast SafeDNS](https://go-acme.github.io/lego/dns/safedns/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) |
| [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex](https://go-acme.github.io/lego/dns/yandex/) | | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) |
| [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | | | [Yandex](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | |
<!-- END DNS PROVIDERS LIST --> <!-- END DNS PROVIDERS LIST -->

View file

@ -94,6 +94,7 @@ func allDNSCodes() string {
"rfc2136", "rfc2136",
"rimuhosting", "rimuhosting",
"route53", "route53",
"safedns",
"sakuracloud", "sakuracloud",
"scaleway", "scaleway",
"selectel", "selectel",
@ -1829,6 +1830,26 @@ func displayDNSHelp(name string) error {
ew.writeln() ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/route53`) ew.writeln(`More information: https://go-acme.github.io/lego/dns/route53`)
case "safedns":
// generated from: providers/dns/safedns/safedns.toml
ew.writeln(`Configuration for UKFast SafeDNS.`)
ew.writeln(`Code: 'safedns'`)
ew.writeln(`Since: 'v4.6.0'`)
ew.writeln()
ew.writeln(`Credentials:`)
ew.writeln(` - "SAFEDNS_AUTH_TOKEN": Authentication token`)
ew.writeln()
ew.writeln(`Additional Configuration:`)
ew.writeln(` - "SAFEDNS_HTTP_TIMEOUT": API request timeout`)
ew.writeln(` - "SAFEDNS_POLLING_INTERVAL": Time between DNS propagation check`)
ew.writeln(` - "SAFEDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
ew.writeln(` - "SAFEDNS_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/safedns`)
case "sakuracloud": case "sakuracloud":
// generated from: providers/dns/sakuracloud/sakuracloud.toml // generated from: providers/dns/sakuracloud/sakuracloud.toml
ew.writeln(`Configuration for Sakura Cloud.`) ew.writeln(`Configuration for Sakura Cloud.`)

View file

@ -0,0 +1,62 @@
---
title: "UKFast SafeDNS"
date: 2019-03-03T16:39:46+01:00
draft: false
slug: safedns
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/safedns/safedns.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Since: v4.6.0
Configuration for [UKFast SafeDNS](https://www.ukfast.co.uk/dns-hosting.html).
<!--more-->
- Code: `safedns`
Here is an example bash command using the UKFast SafeDNS provider:
```bash
SAFEDNS_AUTH_TOKEN=xxxxxx \
lego --email myemail@example.com --dns safedns --domains my.example.org run
```
## Credentials
| Environment Variable Name | Description |
|-----------------------|-------------|
| `SAFEDNS_AUTH_TOKEN` | Authentication token |
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 |
|--------------------------------|-------------|
| `SAFEDNS_HTTP_TIMEOUT` | API request timeout |
| `SAFEDNS_POLLING_INTERVAL` | Time between DNS propagation check |
| `SAFEDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
| `SAFEDNS_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://developers.ukfast.io/documentation/safedns)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/safedns/safedns.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

View file

@ -85,6 +85,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/rfc2136" "github.com/go-acme/lego/v4/providers/dns/rfc2136"
"github.com/go-acme/lego/v4/providers/dns/rimuhosting" "github.com/go-acme/lego/v4/providers/dns/rimuhosting"
"github.com/go-acme/lego/v4/providers/dns/route53" "github.com/go-acme/lego/v4/providers/dns/route53"
"github.com/go-acme/lego/v4/providers/dns/safedns"
"github.com/go-acme/lego/v4/providers/dns/sakuracloud" "github.com/go-acme/lego/v4/providers/dns/sakuracloud"
"github.com/go-acme/lego/v4/providers/dns/scaleway" "github.com/go-acme/lego/v4/providers/dns/scaleway"
"github.com/go-acme/lego/v4/providers/dns/selectel" "github.com/go-acme/lego/v4/providers/dns/selectel"
@ -269,6 +270,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return rimuhosting.NewDNSProvider() return rimuhosting.NewDNSProvider()
case "route53": case "route53":
return route53.NewDNSProvider() return route53.NewDNSProvider()
case "safedns":
return safedns.NewDNSProvider()
case "sakuracloud": case "sakuracloud":
return sakuracloud.NewDNSProvider() return sakuracloud.NewDNSProvider()
case "scaleway": case "scaleway":

View file

@ -0,0 +1,134 @@
package internal
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strconv"
"time"
"github.com/go-acme/lego/v4/challenge/dns01"
)
const defaultBaseURL = "https://api.ukfast.io/safedns/v1"
// Client the UKFast SafeDNS client.
type Client struct {
authToken string
baseURL *url.URL
HTTPClient *http.Client
}
// NewClient Creates a new Client.
func NewClient(authToken string) *Client {
baseURL, _ := url.Parse(defaultBaseURL)
return &Client{
authToken: authToken,
baseURL: baseURL,
HTTPClient: &http.Client{Timeout: 5 * time.Second},
}
}
// AddRecord adds a DNS record.
func (c *Client) AddRecord(zone string, record Record) (*AddRecordResponse, error) {
body, err := json.Marshal(record)
if err != nil {
return nil, err
}
endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "zones", dns01.UnFqdn(zone), "records"))
if err != nil {
return nil, err
}
req, err := c.newRequest(http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= http.StatusBadRequest {
return nil, readError(req, resp)
}
content, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.New(toUnreadableBodyMessage(req, content))
}
respData := &AddRecordResponse{}
err = json.Unmarshal(content, respData)
if err != nil {
return nil, fmt.Errorf("%w: %s", err, toUnreadableBodyMessage(req, content))
}
return respData, nil
}
// RemoveRecord removes a DNS record.
func (c *Client) RemoveRecord(zone string, recordID int) error {
endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "zones", dns01.UnFqdn(zone), "records", strconv.Itoa(recordID)))
if err != nil {
return err
}
req, err := c.newRequest(http.MethodDelete, endpoint.String(), nil)
if err != nil {
return err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
return readError(req, resp)
}
return nil
}
func (c *Client) newRequest(method, endpoint string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequest(method, endpoint, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", c.authToken)
return req, nil
}
func readError(req *http.Request, resp *http.Response) error {
content, err := io.ReadAll(resp.Body)
if err != nil {
return errors.New(toUnreadableBodyMessage(req, content))
}
var errInfo APIError
err = json.Unmarshal(content, &errInfo)
if err != nil {
return fmt.Errorf("unmarshaling error: %w: %s", err, toUnreadableBodyMessage(req, content))
}
return errInfo
}
func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string {
return fmt.Sprintf("the request %s received a response with an invalid format: %q", req.URL, string(rawBody))
}

View file

@ -0,0 +1,117 @@
package internal
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTest(t *testing.T) (*Client, *http.ServeMux) {
t.Helper()
mux := http.NewServeMux()
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
client := NewClient("secret")
client.baseURL, _ = url.Parse(server.URL)
return client, mux
}
func TestClient_AddRecord(t *testing.T) {
client, mux := setupTest(t)
mux.HandleFunc("/zones/example.com/records", func(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
return
}
if req.Header.Get("Authorization") != "secret" {
http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized)
return
}
reqBody, err := io.ReadAll(req.Body)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
expectedReqBody := `{"name":"_acme-challenge.example.com","type":"TXT","content":"\"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI\"","ttl":120}`
if string(reqBody) != expectedReqBody {
http.Error(rw, `{"message":"invalid request"}`, http.StatusBadRequest)
return
}
resp := `{
"data": {
"id": 1234567
},
"meta": {
"location": "https://api.ukfast.io/safedns/v1/zones/example.com/records/1234567"
}
}`
rw.WriteHeader(http.StatusCreated)
_, err = fmt.Fprint(rw, resp)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
})
record := Record{
Name: "_acme-challenge.example.com",
Type: "TXT",
Content: `"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI"`,
TTL: dns01.DefaultTTL,
}
response, err := client.AddRecord("example.com", record)
require.NoError(t, err)
expected := &AddRecordResponse{
Data: struct {
ID int `json:"id"`
}{
ID: 1234567,
},
Meta: struct {
Location string `json:"location"`
}{
Location: "https://api.ukfast.io/safedns/v1/zones/example.com/records/1234567",
},
}
assert.Equal(t, expected, response)
}
func TestClient_RemoveRecord(t *testing.T) {
client, mux := setupTest(t)
mux.HandleFunc("/zones/example.com/records/1234567", func(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodDelete {
http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest)
return
}
if req.Header.Get("Authorization") != "secret" {
http.Error(rw, `{"message":"Unauthenticated"}`, http.StatusUnauthorized)
return
}
rw.WriteHeader(http.StatusNoContent)
})
err := client.RemoveRecord("example.com", 1234567)
require.NoError(t, err)
}

View file

@ -0,0 +1,25 @@
package internal
type AddRecordResponse struct {
Data struct {
ID int `json:"id"`
} `json:"data"`
Meta struct {
Location string `json:"location"`
}
}
type Record struct {
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
TTL int `json:"ttl"`
}
type APIError struct {
Message string `json:"message"`
}
func (a APIError) Error() string {
return a.Message
}

View file

@ -0,0 +1,155 @@
// Package safedns implements a DNS provider for solving the DNS-01 challenge using UKFast SafeDNS.
package safedns
import (
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/platform/config/env"
"github.com/go-acme/lego/v4/providers/dns/safedns/internal"
)
// Environment variables.
const (
envNamespace = "SAFEDNS_"
EnvAuthToken = envNamespace + "AUTH_TOKEN"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
// Config is used to configure the creation of the DNSProvider.
type Config struct {
AuthToken string
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
HTTPClient *http.Client
}
// NewDefaultConfig returns a default configuration for the DNSProvider.
func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
},
}
}
// DNSProvider implements the challenge.Provider interface.
type DNSProvider struct {
config *Config
client *internal.Client
recordIDs map[string]int
recordIDsMu sync.Mutex
}
// NewDNSProvider returns a DNSProvider instance.
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvAuthToken)
if err != nil {
return nil, fmt.Errorf("safedns: %w", err)
}
config := NewDefaultConfig()
config.AuthToken = values[EnvAuthToken]
return NewDNSProviderConfig(config)
}
// NewDNSProviderConfig return a DNSProvider instance configured for UKFast SafeDNS.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("safedns: supplied configuration was nil")
}
if config.AuthToken == "" {
return nil, errors.New("safedns: credentials missing")
}
client := internal.NewClient(config.AuthToken)
if config.HTTPClient != nil {
client.HTTPClient = config.HTTPClient
}
return &DNSProvider{
config: config,
client: client,
recordIDs: make(map[string]int),
}, 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)
zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(fqdn))
if err != nil {
return fmt.Errorf("safedns: could not determine zone for domain: %q: %w", fqdn, err)
}
record := internal.Record{
Name: dns01.UnFqdn(fqdn),
Type: "TXT",
Content: fmt.Sprintf("%q", value),
TTL: d.config.TTL,
}
resp, err := d.client.AddRecord(zone, record)
if err != nil {
return fmt.Errorf("safedns: %w", err)
}
d.recordIDsMu.Lock()
d.recordIDs[token] = resp.Data.ID
d.recordIDsMu.Unlock()
return nil
}
// CleanUp removes the TXT record previously created.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _ := dns01.GetRecord(domain, keyAuth)
authZone, err := dns01.FindZoneByFqdn(fqdn)
if err != nil {
return fmt.Errorf("safedns: %w", err)
}
d.recordIDsMu.Lock()
recordID, ok := d.recordIDs[token]
d.recordIDsMu.Unlock()
if !ok {
return fmt.Errorf("safedns: unknown record ID for '%s'", fqdn)
}
err = d.client.RemoveRecord(authZone, recordID)
if err != nil {
return fmt.Errorf("safedns: %w", err)
}
d.recordIDsMu.Lock()
delete(d.recordIDs, token)
d.recordIDsMu.Unlock()
return nil
}

View file

@ -0,0 +1,22 @@
Name = "UKFast SafeDNS"
Description = ''''''
URL = "https://www.ukfast.co.uk/dns-hosting.html"
Code = "safedns"
Since = "v4.6.0"
Example = '''
SAFEDNS_AUTH_TOKEN=xxxxxx \
lego --email myemail@example.com --dns safedns --domains my.example.org run
'''
[Configuration]
[Configuration.Credentials]
SAFEDNS_AUTH_TOKEN = "Authentication token"
[Configuration.Additional]
SAFEDNS_POLLING_INTERVAL = "Time between DNS propagation check"
SAFEDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
SAFEDNS_TTL = "The TTL of the TXT record used for the DNS challenge"
SAFEDNS_HTTP_TIMEOUT = "API request timeout"
[Links]
API = "https://developers.ukfast.io/documentation/safedns"

View file

@ -0,0 +1,118 @@
package safedns
import (
"testing"
"time"
"github.com/go-acme/lego/v4/platform/tester"
"github.com/stretchr/testify/require"
)
const envDomain = envNamespace + "DOMAIN"
var envTest = tester.NewEnvTest(EnvAuthToken).WithDomain(envDomain)
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
desc string
envVars map[string]string
expected string
}{
{
desc: "success",
envVars: map[string]string{
EnvAuthToken: "123",
},
},
{
desc: "missing credentials",
envVars: map[string]string{
EnvAuthToken: "",
},
expected: "safedns: some credentials information are missing: SAFEDNS_AUTH_TOKEN",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
envTest.ClearEnv()
envTest.Apply(test.envVars)
p, err := NewDNSProvider()
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
require.NotNil(t, p.recordIDs)
} else {
require.EqualError(t, err, test.expected)
}
})
}
}
func TestNewDNSProviderConfig(t *testing.T) {
testCases := []struct {
desc string
authToken string
expected string
}{
{
desc: "success",
authToken: "123",
},
{
desc: "missing credentials",
expected: "safedns: credentials missing",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
config := NewDefaultConfig()
config.AuthToken = test.authToken
p, err := NewDNSProviderConfig(config)
if test.expected == "" {
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
require.NotNil(t, p.recordIDs)
} 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)
}