Skip to content

Commit

Permalink
Add SafeDNS dns01 provider
Browse files Browse the repository at this point in the history
  • Loading branch information
Xiol committed Dec 16, 2021
1 parent 0f3a835 commit 28fe672
Show file tree
Hide file tree
Showing 5 changed files with 468 additions and 0 deletions.
3 changes: 3 additions & 0 deletions providers/dns/dns_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import (
"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/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/scaleway"
"github.com/go-acme/lego/v4/providers/dns/selectel"
Expand Down Expand Up @@ -269,6 +270,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return rimuhosting.NewDNSProvider()
case "route53":
return route53.NewDNSProvider()
case "safedns":
return safedns.NewDNSProvider()
case "sakuracloud":
return sakuracloud.NewDNSProvider()
case "scaleway":
Expand Down
146 changes: 146 additions & 0 deletions providers/dns/safedns/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package safedns

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"

"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/log"
)

const defaultBaseURL = "https://api.ukfast.io"

type txtResponse struct {
Data struct {
ID int `json:"id"`
} `json:"data"`
Meta struct {
Location string `json:"location"`
}
}

type recordType string

const (
typeTXT recordType = "TXT"
)

type record struct {
Name string `json:"name"`
Type recordType `json:"type"`
Content string `json:"content"`
TTL int `json:"ttl"`
}

type apiError struct {
Message string `json:"message"`
}

func (d *DNSProvider) addTxtRecord(fqdn, value string) (*txtResponse, error) {
zone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(fqdn))
if err != nil {
return nil, fmt.Errorf("could not determine zone for domain: %q: %w", fqdn, err)
}

reqData := record{Name: dns01.UnFqdn(fqdn), Type: typeTXT, Content: fmt.Sprintf("\"%s\"", value), TTL: d.config.TTL}
body, err := json.Marshal(reqData)
if err != nil {
return nil, err
}

url := fmt.Sprintf("%s/safedns/v1/zones/%s/records", d.config.BaseURL, dns01.UnFqdn(zone))
req, err := d.newRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}

log.Infof("safedns: creating record %+v at %s", reqData, url)

resp, err := d.config.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer 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 := &txtResponse{}
err = json.Unmarshal(content, respData)
if err != nil {
return nil, fmt.Errorf("%w: %s", err, toUnreadableBodyMessage(req, content))
}

log.Infof("safedns: created record with ID %d", respData.Data.ID)

return respData, nil
}

func (d *DNSProvider) removeTxtRecord(domain string, recordID int) error {
authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain))
if err != nil {
return fmt.Errorf("safedns: could not determine zone for domain %q: %w", domain, err)
}

reqURL := fmt.Sprintf("%s/safedns/v1/zones/%s/records/%d", d.config.BaseURL, dns01.UnFqdn(authZone), recordID)
req, err := d.newRequest(http.MethodDelete, reqURL, nil)
if err != nil {
return err
}

log.Infof("safedns: cleaning up record %d at %s", recordID, reqURL)

resp, err := d.config.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode >= 400 {
return readError(req, resp)
}

return nil
}

func (d *DNSProvider) newRequest(method, url string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", d.config.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("safedns: unmarshaling error: %w: %s", err, toUnreadableBodyMessage(req, content))
}

return fmt.Errorf("safedns: HTTP %d: %s", resp.StatusCode, content)
}

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))
}
127 changes: 127 additions & 0 deletions providers/dns/safedns/safedns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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"
)

// Environment variables.
const (
envNamespace = "SAFEDNS_"

EnvAuthToken = envNamespace + "AUTH_TOKEN"
EnvTTL = envNamespace + "TTL"
EnvAPITimeout = envNamespace + "API_TIMEOUT"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
)

type Config struct {
BaseURL string
AuthToken string
TTL int
APITimeout time.Duration
PropagationTimeout time.Duration
PollingInterval time.Duration
HTTPClient *http.Client
}

func NewDefaultConfig() *Config {
return &Config{
BaseURL: defaultBaseURL,
TTL: env.GetOrDefaultInt(EnvTTL, 30),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Second),
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond(EnvAPITimeout, 30*time.Second),
},
}
}

type DNSProvider struct {
config *Config
recordIDs map[string]int
recordIDsMu sync.Mutex
}

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

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

if config.BaseURL == "" {
config.BaseURL = defaultBaseURL
}

return &DNSProvider{
config: config,
recordIDs: make(map[string]int),
}, nil
}

func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}

func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value := dns01.GetRecord(domain, keyAuth)

respData, err := d.addTxtRecord(fqdn, value)
if err != nil {
return fmt.Errorf("safedns: %w", err)
}

d.recordIDsMu.Lock()
d.recordIDs[token] = respData.Data.ID
d.recordIDsMu.Unlock()

return nil
}

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.removeTxtRecord(authZone, recordID)
if err != nil {
return fmt.Errorf("safedns: %w", err)
}

d.recordIDsMu.Lock()
delete(d.recordIDs, token)
d.recordIDsMu.Unlock()

return nil
}
22 changes: 22 additions & 0 deletions providers/dns/safedns/safedns.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Name = "SafeDNS"
Description = ''''''
URL = "https://www.ukfast.co.uk/dns-hosting.html"
Code = "safedns"
Since = "v4.6.0"

Example = '''
SAFEDNS_AUTH_TOKEN=xxxxxx \
lego --email [email protected] --dns safedns --domains my.example.org run
'''

[Configuration]
[Configuration.Credentials]
SAFEDNS_AUTH_TOKEN = "Authentication token"
[Configuration.Additional]
SAFEDNS_TTL = "TXT record TTL"
SAFEDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
SAFEDNS_API_TIMEOUT = "API request timeout in seconds"
SAFEDNS_POLLING_INTERVAL = "Time to wait for initial check"

[Links]
API = "https://developers.ukfast.io/documentation/safedns"
Loading

0 comments on commit 28fe672

Please sign in to comment.