From 30847bcfbc88abf8a3e618dde6822866476ed125 Mon Sep 17 00:00:00 2001 From: Dane Elwell Date: Thu, 16 Dec 2021 13:57:59 +0000 Subject: [PATCH] Add SafeDNS dns01 provider --- README.md | 10 +- cmd/zz_gen_cmd_dnshelp.go | 21 ++++ docs/content/dns/zz_gen_safedns.md | 62 ++++++++++ providers/dns/dns_providers.go | 3 + providers/dns/safedns/client.go | 146 ++++++++++++++++++++++ providers/dns/safedns/safedns.go | 127 +++++++++++++++++++ providers/dns/safedns/safedns.toml | 22 ++++ providers/dns/safedns/safedns_test.go | 169 ++++++++++++++++++++++++++ 8 files changed, 555 insertions(+), 5 deletions(-) create mode 100644 docs/content/dns/zz_gen_safedns.md create mode 100644 providers/dns/safedns/client.go create mode 100644 providers/dns/safedns/safedns.go create mode 100644 providers/dns/safedns/safedns.toml create mode 100644 providers/dns/safedns/safedns_test.go diff --git a/README.md b/README.md index dffd7ef3bbb..e8a796e980a 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,11 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [Nicmanager](https://go-acme.github.io/lego/dns/nicmanager/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [Njalla](https://go-acme.github.io/lego/dns/njalla/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) | | [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/) | -| [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/) | -| [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/) | -| [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | | +| [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [SafeDNS](https://go-acme.github.io/lego/dns/safedns/) | [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/) | [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/) | [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/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 8b3e95e2560..167624bc829 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -94,6 +94,7 @@ func allDNSCodes() string { "rfc2136", "rimuhosting", "route53", + "safedns", "sakuracloud", "scaleway", "selectel", @@ -1829,6 +1830,26 @@ func displayDNSHelp(name string) error { ew.writeln() 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 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_API_TIMEOUT": API request timeout in seconds`) + ew.writeln(` - "SAFEDNS_POLLING_INTERVAL": Time to wait for initial check`) + ew.writeln(` - "SAFEDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "SAFEDNS_TTL": TXT record TTL`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/safedns`) + case "sakuracloud": // generated from: providers/dns/sakuracloud/sakuracloud.toml ew.writeln(`Configuration for Sakura Cloud.`) diff --git a/docs/content/dns/zz_gen_safedns.md b/docs/content/dns/zz_gen_safedns.md new file mode 100644 index 00000000000..1aec094d865 --- /dev/null +++ b/docs/content/dns/zz_gen_safedns.md @@ -0,0 +1,62 @@ +--- +title: "SafeDNS" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: safedns +--- + + + + + +Since: v4.6.0 + +Configuration for [SafeDNS](https://www.ukfast.co.uk/dns-hosting.html). + + + + +- Code: `safedns` + +Here is an example bash command using the 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_API_TIMEOUT` | API request timeout in seconds | +| `SAFEDNS_POLLING_INTERVAL` | Time to wait for initial check | +| `SAFEDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `SAFEDNS_TTL` | TXT record TTL | + +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) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index ae25512ac62..f8f9b317e70 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -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" @@ -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": diff --git a/providers/dns/safedns/client.go b/providers/dns/safedns/client.go new file mode 100644 index 00000000000..1404d89de36 --- /dev/null +++ b/providers/dns/safedns/client.go @@ -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)) +} diff --git a/providers/dns/safedns/safedns.go b/providers/dns/safedns/safedns.go new file mode 100644 index 00000000000..9dd1616f1db --- /dev/null +++ b/providers/dns/safedns/safedns.go @@ -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 +} diff --git a/providers/dns/safedns/safedns.toml b/providers/dns/safedns/safedns.toml new file mode 100644 index 00000000000..57afa97c975 --- /dev/null +++ b/providers/dns/safedns/safedns.toml @@ -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 myemail@example.com --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" diff --git a/providers/dns/safedns/safedns_test.go b/providers/dns/safedns/safedns_test.go new file mode 100644 index 00000000000..b4d75c1b4e5 --- /dev/null +++ b/providers/dns/safedns/safedns_test.go @@ -0,0 +1,169 @@ +package safedns + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var envTest = tester.NewEnvTest(EnvAuthToken) + +func setupTest(t *testing.T) (*DNSProvider, *http.ServeMux) { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + config := NewDefaultConfig() + config.AuthToken = "asdf1234" + config.BaseURL = server.URL + config.HTTPClient = server.Client() + + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) + + return provider, mux +} + +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 TestDNSProvider_Present(t *testing.T) { + provider, mux := setupTest(t) + + mux.HandleFunc("/safedns/v1/zones/example.com/records", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "method") + + assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") + assert.Equal(t, "asdf1234", r.Header.Get("Authorization"), "Authorization") + + reqBody, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + expectedReqBody := `{"name":"_acme-challenge.example.com.","type":"TXT","content":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}` + assert.Equal(t, expectedReqBody, string(reqBody)) + + w.WriteHeader(http.StatusCreated) + _, err = fmt.Fprintf(w, `{ + "data": { + "id": 1234567 + }, + "meta": { + "location": "https://api.ukfast.io/safedns/v1/zones/example.com/records/1234567" + } + }`) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + + err := provider.Present("example.com", "", "foobar") + require.NoError(t, err) +} + +func TestDNSProvider_CleanUp(t *testing.T) { + provider, mux := setupTest(t) + + mux.HandleFunc("/safedns/v1/zones/example.com/records/1234567", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "method") + + assert.Equal(t, "/safedns/v1/zones/example.com/records/1234567", r.URL.Path, "Path") + + assert.Equal(t, "application/json", r.Header.Get("Content-Type"), "Content-Type") + assert.Equal(t, "asdf1234", r.Header.Get("Authorization"), "Authorization") + + w.WriteHeader(http.StatusNoContent) + }) + + provider.recordIDsMu.Lock() + provider.recordIDs["token"] = 1234567 + provider.recordIDsMu.Unlock() + + err := provider.CleanUp("example.com", "token", "") + require.NoError(t, err, "fail to remove TXT record") +}