From 484f0e5e35de9179500cb1a6e97ff3cb91b30343 Mon Sep 17 00:00:00 2001 From: Craig Steinberger Date: Sat, 13 Oct 2018 10:01:46 -0400 Subject: [PATCH] Add DNS Provider for DreamHost (#668) * add support for DreamHost DNS --- cli.go | 2 + providers/dns/dns_providers.go | 3 + providers/dns/dreamhost/client.go | 73 ++++++++ providers/dns/dreamhost/client_test.go | 63 +++++++ providers/dns/dreamhost/dreamhost.go | 111 +++++++++++ providers/dns/dreamhost/dreamhost_test.go | 214 ++++++++++++++++++++++ 6 files changed, 466 insertions(+) create mode 100644 providers/dns/dreamhost/client.go create mode 100644 providers/dns/dreamhost/client_test.go create mode 100644 providers/dns/dreamhost/dreamhost.go create mode 100644 providers/dns/dreamhost/dreamhost_test.go 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/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.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 +} 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) +}