Repository: xenolf/lego (go)

Issue:
Add DNS Challenge support for domains hosted by DreamHost
[DreamHost](https://www.dreamhost.com/domains/) offers DNS Management for domains registered through them, and offers an [API](https://help.dreamhost.com/hc/en-us/articles/217560167-Application-programming-interface-overview). 

Pull request:
Add DNS Provider for DreamHost
Fixes #667.

This adds DNS Provider support for [Dreamhost](https://www.dreamhost.com/domains/) through their [API](https://help.dreamhost.com/hc/en-us/articles/217555707-DNS-API-commands).

In addition to the tests included in the PR, I manually live tested. The only issue is DreamHost has very slow propogation. In my test case it took around 50 minutes for the record to propagate.

Patch:
diff --git a/cli.go b/cli.go
index 9f57e825..a45b3c73 100644
--- a/cli.go
+++ b/cli.go
@@ -213,6 +213,7 @@ Here is an example bash command using the CloudFlare DNS provider:
 	fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_OAUTH_TOKEN")
 	fmt.Fprintln(w, "\tdnsmadeeasy:\tDNSMADEEASY_API_KEY, DNSMADEEASY_API_SECRET")
 	fmt.Fprintln(w, "\tdnspod:\tDNSPOD_API_KEY")
+	fmt.Fprintln(w, "\tdreamhost:\tDREAMHOST_API_KEY")
 	fmt.Fprintln(w, "\tduckdns:\tDUCKDNS_TOKEN")
 	fmt.Fprintln(w, "\tdyn:\tDYN_CUSTOMER_NAME, DYN_USER_NAME, DYN_PASSWORD")
 	fmt.Fprintln(w, "\texec:\tEXEC_PATH, EXEC_MODE")
@@ -257,6 +258,7 @@ Here is an example bash command using the CloudFlare DNS provider:
 	fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_TTL, DNSIMPLE_POLLING_INTERVAL, DNSIMPLE_PROPAGATION_TIMEOUT")
 	fmt.Fprintln(w, "\tdnsmadeeasy:\tDNSMADEEASY_POLLING_INTERVAL, DNSMADEEASY_PROPAGATION_TIMEOUT, DNSMADEEASY_TTL, DNSMADEEASY_HTTP_TIMEOUT")
 	fmt.Fprintln(w, "\tdnspod:\tDNSPOD_POLLING_INTERVAL, DNSPOD_PROPAGATION_TIMEOUT, DNSPOD_TTL, DNSPOD_HTTP_TIMEOUT")
+	fmt.Fprintln(w, "\tdreamhost:\tDREAMHOST_POLLING_INTERVAL, DREAMHOST_PROPAGATION_TIMEOUT, DREAMHOST_HTTP_TIMEOUT")
 	fmt.Fprintln(w, "\tduckdns:\tDUCKDNS_POLLING_INTERVAL, DUCKDNS_PROPAGATION_TIMEOUT, DUCKDNS_HTTP_TIMEOUT")
 	fmt.Fprintln(w, "\tdyn:\tDYN_POLLING_INTERVAL, DYN_PROPAGATION_TIMEOUT, DYN_TTL, DYN_HTTP_TIMEOUT")
 	fmt.Fprintln(w, "\texoscale:\tEXOSCALE_POLLING_INTERVAL, EXOSCALE_PROPAGATION_TIMEOUT, EXOSCALE_TTL, EXOSCALE_HTTP_TIMEOUT")
diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go
index 37734663..ff7a7d83 100644
--- a/providers/dns/dns_providers.go
+++ b/providers/dns/dns_providers.go
@@ -15,6 +15,7 @@ import (
 	"github.com/xenolf/lego/providers/dns/dnsimple"
 	"github.com/xenolf/lego/providers/dns/dnsmadeeasy"
 	"github.com/xenolf/lego/providers/dns/dnspod"
+	"github.com/xenolf/lego/providers/dns/dreamhost"
 	"github.com/xenolf/lego/providers/dns/duckdns"
 	"github.com/xenolf/lego/providers/dns/dyn"
 	"github.com/xenolf/lego/providers/dns/exec"
@@ -72,6 +73,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error)
 		return dnsmadeeasy.NewDNSProvider()
 	case "dnspod":
 		return dnspod.NewDNSProvider()
+	case "dreamhost":
+		return dreamhost.NewDNSProvider()
 	case "duckdns":
 		return duckdns.NewDNSProvider()
 	case "dyn":
diff --git a/providers/dns/dreamhost/client.go b/providers/dns/dreamhost/client.go
new file mode 100644
index 00000000..640f9604
--- /dev/null
+++ b/providers/dns/dreamhost/client.go
@@ -0,0 +1,73 @@
+package dreamhost
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/url"
+
+	"github.com/xenolf/lego/log"
+)
+
+const (
+	defaultBaseURL = "https://api.dreamhost.com"
+
+	cmdAddRecord    = "dns-add_record"
+	cmdRemoveRecord = "dns-remove_record"
+)
+
+type apiResponse struct {
+	Data   string `json:"data"`
+	Result string `json:"result"`
+}
+
+func (d *DNSProvider) buildQuery(action, domain, txt string) (*url.URL, error) {
+	u, err := url.Parse(d.config.BaseURL)
+	if err != nil {
+		return nil, err
+	}
+
+	query := u.Query()
+	query.Set("key", d.config.APIKey)
+	query.Set("cmd", action)
+	query.Set("format", "json")
+	query.Set("record", domain)
+	query.Set("type", "TXT")
+	query.Set("value", txt)
+	query.Set("comment", url.QueryEscape("Managed By lego"))
+	u.RawQuery = query.Encode()
+
+	return u, nil
+}
+
+// updateTxtRecord will either add or remove a TXT record.
+// action is either cmdAddRecord or cmdRemoveRecord
+func (d *DNSProvider) updateTxtRecord(u fmt.Stringer) error {
+	resp, err := d.config.HTTPClient.Get(u.String())
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		return fmt.Errorf("request failed with HTTP status code %d", resp.StatusCode)
+	}
+
+	raw, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return fmt.Errorf("failed to read body: %v", err)
+	}
+
+	var response apiResponse
+	err = json.Unmarshal(raw, &response)
+	if err != nil {
+		return fmt.Errorf("unable to decode API server response: %v: %s", err, string(raw))
+	}
+
+	if response.Result == "error" {
+		return fmt.Errorf("add TXT record failed: %s", response.Data)
+	}
+
+	log.Infof("dreamhost: %s", response.Data)
+	return nil
+}
diff --git a/providers/dns/dreamhost/dreamhost.go b/providers/dns/dreamhost/dreamhost.go
new file mode 100644
index 00000000..cc0e3633
--- /dev/null
+++ b/providers/dns/dreamhost/dreamhost.go
@@ -0,0 +1,111 @@
+// Package dreamhost Adds lego support for http://dreamhost.com DNS updates
+// See https://help.dreamhost.com/hc/en-us/articles/217560167-API_overview
+// and https://help.dreamhost.com/hc/en-us/articles/217555707-DNS-API-commands for the API spec.
+package dreamhost
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/xenolf/lego/acme"
+	"github.com/xenolf/lego/platform/config/env"
+)
+
+// Config is used to configure the creation of the DNSProvider
+type Config struct {
+	BaseURL            string
+	APIKey             string
+	PropagationTimeout time.Duration
+	PollingInterval    time.Duration
+	HTTPClient         *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider
+func NewDefaultConfig() *Config {
+	return &Config{
+		BaseURL:            defaultBaseURL,
+		PropagationTimeout: env.GetOrDefaultSecond("DREAMHOST_PROPAGATION_TIMEOUT", 60*time.Minute),
+		PollingInterval:    env.GetOrDefaultSecond("DREAMHOST_POLLING_INTERVAL", 1*time.Minute),
+		HTTPClient: &http.Client{
+			Timeout: env.GetOrDefaultSecond("DREAMHOST_HTTP_TIMEOUT", 30*time.Second),
+		},
+	}
+}
+
+// DNSProvider adds and removes the record for the DNS challenge
+type DNSProvider struct {
+	config *Config
+}
+
+// NewDNSProvider returns a new DNS provider using
+// environment variable DREAMHOST_TOKEN for adding and removing the DNS record.
+func NewDNSProvider() (*DNSProvider, error) {
+	values, err := env.Get("DREAMHOST_API_KEY")
+	if err != nil {
+		return nil, fmt.Errorf("dreamhost: %v", err)
+	}
+
+	config := NewDefaultConfig()
+	config.APIKey = values["DREAMHOST_API_KEY"]
+
+	return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for DreamHost.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+	if config == nil {
+		return nil, errors.New("dreamhost: the configuration of the DNS provider is nil")
+	}
+
+	if config.APIKey == "" {
+		return nil, errors.New("dreamhost: credentials missing")
+	}
+
+	if config.BaseURL == "" {
+		config.BaseURL = defaultBaseURL
+	}
+
+	return &DNSProvider{config: config}, nil
+}
+
+// Present creates a TXT record to fulfill the dns-01 challenge.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+	fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
+	record := acme.UnFqdn(fqdn)
+
+	u, err := d.buildQuery(cmdAddRecord, record, value)
+	if err != nil {
+		return fmt.Errorf("dreamhost: %v", err)
+	}
+
+	err = d.updateTxtRecord(u)
+	if err != nil {
+		return fmt.Errorf("dreamhost: %v", err)
+	}
+	return nil
+}
+
+// CleanUp clears DreamHost TXT record
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+	fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
+	record := acme.UnFqdn(fqdn)
+
+	u, err := d.buildQuery(cmdRemoveRecord, record, value)
+	if err != nil {
+		return fmt.Errorf("dreamhost: %v", err)
+	}
+
+	err = d.updateTxtRecord(u)
+	if err != nil {
+		return fmt.Errorf("dreamhost: %v", err)
+	}
+	return 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
+}


Test patch:
diff --git a/providers/dns/dreamhost/client_test.go b/providers/dns/dreamhost/client_test.go
new file mode 100644
index 00000000..c8d195bd
--- /dev/null
+++ b/providers/dns/dreamhost/client_test.go
@@ -0,0 +1,63 @@
+package dreamhost
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDNSProvider_buildQuery(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		apiKey   string
+		baseURL  string
+		action   string
+		domain   string
+		txt      string
+		expected string
+	}{
+		{
+			desc:     "success",
+			apiKey:   fakeAPIKey,
+			action:   cmdAddRecord,
+			domain:   "domain",
+			txt:      "TXTtxtTXT",
+			expected: "https://api.dreamhost.com?cmd=dns-add_record&comment=Managed%2BBy%2Blego&format=json&key=asdf1234&record=domain&type=TXT&value=TXTtxtTXT",
+		},
+		{
+			desc:    "Invalid base URL",
+			apiKey:  fakeAPIKey,
+			baseURL: ":",
+			action:  cmdAddRecord,
+			domain:  "domain",
+			txt:     "TXTtxtTXT",
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			config := NewDefaultConfig()
+			config.APIKey = test.apiKey
+			if test.baseURL != "" {
+				config.BaseURL = test.baseURL
+			}
+
+			provider, err := NewDNSProviderConfig(config)
+			require.NoError(t, err)
+			require.NotNil(t, provider)
+
+			u, err := provider.buildQuery(test.action, test.domain, test.txt)
+
+			if test.expected == "" {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				assert.Equal(t, test.expected, u.String())
+			}
+		})
+	}
+}
diff --git a/providers/dns/dreamhost/dreamhost_test.go b/providers/dns/dreamhost/dreamhost_test.go
new file mode 100644
index 00000000..f98354fb
--- /dev/null
+++ b/providers/dns/dreamhost/dreamhost_test.go
@@ -0,0 +1,214 @@
+package dreamhost
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+var (
+	liveTest      bool
+	envTestDomain string
+	envTestAPIKey string
+
+	fakeAPIKey         = "asdf1234"
+	fakeChallengeToken = "foobar"
+	fakeKeyAuth        = "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI"
+)
+
+func init() {
+	envTestAPIKey = os.Getenv("DREAMHOST_API_KEY")
+	envTestDomain = os.Getenv("DREAMHOST_TEST_DOMAIN")
+
+	if len(envTestAPIKey) > 0 && len(envTestDomain) > 0 {
+		liveTest = true
+	}
+}
+
+func restoreEnv() {
+	os.Setenv("DREAMHOST_API_KEY", envTestAPIKey)
+}
+
+func setupTest() (*DNSProvider, *http.ServeMux, func()) {
+	handler := http.NewServeMux()
+	server := httptest.NewServer(handler)
+
+	config := NewDefaultConfig()
+	config.APIKey = fakeAPIKey
+	config.BaseURL = server.URL
+
+	provider, err := NewDNSProviderConfig(config)
+	if err != nil {
+		panic(err)
+	}
+
+	return provider, handler, server.Close
+}
+
+func TestNewDNSProvider(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		envVars  map[string]string
+		expected string
+	}{
+		{
+			desc: "success",
+			envVars: map[string]string{
+				"DREAMHOST_API_KEY": "123",
+			},
+		},
+		{
+			desc: "missing API key",
+			envVars: map[string]string{
+				"DREAMHOST_API_KEY": "",
+			},
+			expected: "dreamhost: some credentials information are missing: DREAMHOST_API_KEY",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			defer restoreEnv()
+			for key, value := range test.envVars {
+				if len(value) == 0 {
+					os.Unsetenv(key)
+				} else {
+					os.Setenv(key, value)
+				}
+			}
+
+			p, err := NewDNSProvider()
+
+			if len(test.expected) == 0 {
+				assert.NoError(t, err)
+				assert.NotNil(t, p)
+			} else {
+				require.EqualError(t, err, test.expected)
+			}
+		})
+	}
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		apiKey   string
+		expected string
+	}{
+		{
+			desc:   "success",
+			apiKey: "123",
+		},
+		{
+			desc:     "missing credentials",
+			expected: "dreamhost: credentials missing",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			config := NewDefaultConfig()
+			config.APIKey = test.apiKey
+
+			p, err := NewDNSProviderConfig(config)
+
+			if len(test.expected) == 0 {
+				assert.NoError(t, err)
+				assert.NotNil(t, p)
+			} else {
+				require.EqualError(t, err, test.expected)
+			}
+		})
+	}
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+	provider, mux, tearDown := setupTest()
+	defer tearDown()
+
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "method")
+
+		q := r.URL.Query()
+		assert.Equal(t, q.Get("key"), fakeAPIKey)
+		assert.Equal(t, q.Get("cmd"), "dns-add_record")
+		assert.Equal(t, q.Get("format"), "json")
+		assert.Equal(t, q.Get("record"), "_acme-challenge.example.com")
+		assert.Equal(t, q.Get("value"), fakeKeyAuth)
+		assert.Equal(t, q.Get("comment"), "Managed+By+lego")
+
+		_, err := fmt.Fprintf(w, `{"data":"record_added","result":"success"}`)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+	})
+
+	err := provider.Present("example.com", "", fakeChallengeToken)
+	require.NoError(t, err)
+}
+
+func TestDNSProvider_PresentFailed(t *testing.T) {
+	provider, mux, tearDown := setupTest()
+	defer tearDown()
+
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "method")
+
+		_, err := fmt.Fprintf(w, `{"data":"record_already_exists_remove_first","result":"error"}`)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+	})
+
+	err := provider.Present("example.com", "", fakeChallengeToken)
+	require.EqualError(t, err, "dreamhost: add TXT record failed: record_already_exists_remove_first")
+}
+
+func TestDNSProvider_Cleanup(t *testing.T) {
+	provider, mux, tearDown := setupTest()
+	defer tearDown()
+
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "method")
+
+		q := r.URL.Query()
+		assert.Equal(t, q.Get("key"), fakeAPIKey, "key mismatch")
+		assert.Equal(t, q.Get("cmd"), "dns-remove_record", "cmd mismatch")
+		assert.Equal(t, q.Get("format"), "json")
+		assert.Equal(t, q.Get("record"), "_acme-challenge.example.com")
+		assert.Equal(t, q.Get("value"), fakeKeyAuth, "value mismatch")
+		assert.Equal(t, q.Get("comment"), "Managed+By+lego")
+
+		_, err := fmt.Fprintf(w, `{"data":"record_removed","result":"success"}`)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+	})
+
+	err := provider.CleanUp("example.com", "", fakeChallengeToken)
+	require.NoError(t, err, "failed to remove TXT record")
+}
+
+func TestLivePresentAndCleanUp(t *testing.T) {
+	if !liveTest {
+		t.Skip("skipping live test")
+	}
+
+	restoreEnv()
+	provider, err := NewDNSProvider()
+	require.NoError(t, err)
+
+	err = provider.Present(envTestDomain, "", "123d==")
+	require.NoError(t, err)
+
+	time.Sleep(1 * time.Second)
+
+	err = provider.CleanUp(envTestDomain, "", "123d==")
+	require.NoError(t, err)
+}