diff --git a/cli.go b/cli.go index 4ed204e1..8aa7de15 100644 --- a/cli.go +++ b/cli.go @@ -231,6 +231,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tlinode:\tLINODE_API_KEY") fmt.Fprintln(w, "\tlinodev4:\tLINODE_TOKEN") fmt.Fprintln(w, "\tmanual:\tnone") + fmt.Fprintln(w, "\tmydnsjp:\tMYDNSJP_MASTER_ID, MYDNSJP_PASSWORD") fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_API_USER, NAMECHEAP_API_KEY") fmt.Fprintln(w, "\tnamedotcom:\tNAMECOM_USERNAME, NAMECOM_API_TOKEN") fmt.Fprintln(w, "\tnetcup:\tNETCUP_CUSTOMER_NUMBER, NETCUP_API_KEY, NETCUP_API_PASSWORD") diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 1b9842c9..c6a8bdcf 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -32,6 +32,7 @@ import ( "github.com/xenolf/lego/providers/dns/lightsail" "github.com/xenolf/lego/providers/dns/linode" "github.com/xenolf/lego/providers/dns/linodev4" + "github.com/xenolf/lego/providers/dns/mydnsjp" "github.com/xenolf/lego/providers/dns/namecheap" "github.com/xenolf/lego/providers/dns/namedotcom" "github.com/xenolf/lego/providers/dns/netcup" @@ -111,6 +112,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error) return linodev4.NewDNSProvider() case "manual": return acme.NewDNSProviderManual() + case "mydnsjp": + return mydnsjp.NewDNSProvider() case "namecheap": return namecheap.NewDNSProvider() case "namedotcom": diff --git a/providers/dns/mydnsjp/mydnsjp.go b/providers/dns/mydnsjp/mydnsjp.go new file mode 100644 index 00000000..05cad568 --- /dev/null +++ b/providers/dns/mydnsjp/mydnsjp.go @@ -0,0 +1,140 @@ +// Package mydnsjp implements a DNS provider for solving the DNS-01 +// challenge using MyDNS.jp. +package mydnsjp + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" + + "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/platform/config/env" +) + +const defaultBaseURL = "https://www.mydns.jp/directedit.html" + +// Config is used to configure the creation of the DNSProvider +type Config struct { + MasterID string + Password string + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond("MYDNSJP_PROPAGATION_TIMEOUT", 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond("MYDNSJP_POLLING_INTERVAL", 2*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("MYDNSJP_HTTP_TIMEOUT", 30*time.Second), + }, + } +} + +// DNSProvider is an implementation of the acme.ChallengeProvider interface +type DNSProvider struct { + config *Config +} + +// NewDNSProvider returns a DNSProvider instance configured for MyDNS.jp. +// Credentials must be passed in the environment variables: MYDNSJP_MASTER_ID and MYDNSJP_PASSWORD. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get("MYDNSJP_MASTER_ID", "MYDNSJP_PASSWORD") + if err != nil { + return nil, fmt.Errorf("mydnsjp: %v", err) + } + + config := NewDefaultConfig() + config.MasterID = values["MYDNSJP_MASTER_ID"] + config.Password = values["MYDNSJP_PASSWORD"] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for MyDNS.jp. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("mydnsjp: the configuration of the DNS provider is nil") + } + + if config.MasterID == "" || config.Password == "" { + return nil, errors.New("mydnsjp: some credentials information are missing") + } + + return &DNSProvider{config: config}, 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 { + _, value, _ := acme.DNS01Record(domain, keyAuth) + err := d.doRequest(domain, value, "REGIST") + if err != nil { + return fmt.Errorf("mydnsjp: %v", err) + } + return nil +} + +// CleanUp removes the TXT record matching the specified parameters +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + _, value, _ := acme.DNS01Record(domain, keyAuth) + err := d.doRequest(domain, value, "DELETE") + if err != nil { + return fmt.Errorf("mydnsjp: %v", err) + } + return nil +} + +func (d *DNSProvider) doRequest(domain, value string, cmd string) error { + req, err := d.buildRequest(domain, value, cmd) + if err != nil { + return err + } + + resp, err := d.config.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("error querying API: %v", err) + } + + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + var content []byte + content, err = ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + return fmt.Errorf("request %s failed [status code %d]: %s", req.URL, resp.StatusCode, string(content)) + } + + return nil +} + +func (d *DNSProvider) buildRequest(domain, value string, cmd string) (*http.Request, error) { + params := url.Values{} + params.Set("CERTBOT_DOMAIN", domain) + params.Set("CERTBOT_VALIDATION", value) + params.Set("EDIT_CMD", cmd) + + req, err := http.NewRequest(http.MethodPost, defaultBaseURL, strings.NewReader(params.Encode())) + if err != nil { + return nil, fmt.Errorf("invalid request: %v", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(d.config.MasterID, d.config.Password) + + return req, nil +} diff --git a/providers/dns/mydnsjp/mydnsjp_test.go b/providers/dns/mydnsjp/mydnsjp_test.go new file mode 100644 index 00000000..23a71e33 --- /dev/null +++ b/providers/dns/mydnsjp/mydnsjp_test.go @@ -0,0 +1,146 @@ +package mydnsjp + +import ( + "testing" + "time" + + "github.com/xenolf/lego/platform/tester" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var envTest = tester.NewEnvTest("MYDNSJP_MASTER_ID", "MYDNSJP_PASSWORD"). + WithDomain("MYDNSJP_DOMAIN") + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + "MYDNSJP_MASTER_ID": "test@example.com", + "MYDNSJP_PASSWORD": "123", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + "MYDNSJP_MASTER_ID": "", + "MYDNSJP_PASSWORD": "", + }, + expected: "mydnsjp: some credentials information are missing: MYDNSJP_MASTER_ID,MYDNSJP_PASSWORD", + }, + { + desc: "missing email", + envVars: map[string]string{ + "MYDNSJP_MASTER_ID": "", + "MYDNSJP_PASSWORD": "key", + }, + expected: "mydnsjp: some credentials information are missing: MYDNSJP_MASTER_ID", + }, + { + desc: "missing api key", + envVars: map[string]string{ + "MYDNSJP_MASTER_ID": "awesome@possum.com", + "MYDNSJP_PASSWORD": "", + }, + expected: "mydnsjp: some credentials information are missing: MYDNSJP_PASSWORD", + }, + } + + 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 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 + masterID string + password string + expected string + }{ + { + desc: "success", + masterID: "test@example.com", + password: "123", + }, + { + desc: "missing credentials", + expected: "mydnsjp: some credentials information are missing", + }, + { + desc: "missing email", + password: "123", + expected: "mydnsjp: some credentials information are missing", + }, + { + desc: "missing api key", + masterID: "test@example.com", + expected: "mydnsjp: some credentials information are missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.MasterID = test.masterID + config.Password = test.password + + 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 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==") + assert.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(2 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + assert.NoError(t, err) +}