diff --git a/cli.go b/cli.go index b94c7b6d..3fa35c61 100644 --- a/cli.go +++ b/cli.go @@ -188,6 +188,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY") fmt.Fprintln(w, "\tdigitalocean:\tDO_AUTH_TOKEN") fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_API_KEY") + fmt.Fprintln(w, "\tdnsmadeeasy:\tDNSMADEEASY_API_KEY, DNSMADEEASY_API_SECRET") fmt.Fprintln(w, "\tgandi:\tGANDI_API_KEY") fmt.Fprintln(w, "\tgcloud:\tGCE_PROJECT") fmt.Fprintln(w, "\tmanual:\tnone") diff --git a/cli_handlers.go b/cli_handlers.go index 6963a545..f517343f 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -17,6 +17,7 @@ import ( "github.com/xenolf/lego/providers/dns/cloudflare" "github.com/xenolf/lego/providers/dns/digitalocean" "github.com/xenolf/lego/providers/dns/dnsimple" + "github.com/xenolf/lego/providers/dns/dnsmadeeasy" "github.com/xenolf/lego/providers/dns/dyn" "github.com/xenolf/lego/providers/dns/gandi" "github.com/xenolf/lego/providers/dns/googlecloud" @@ -107,6 +108,8 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { provider, err = digitalocean.NewDNSProvider() case "dnsimple": provider, err = dnsimple.NewDNSProvider() + case "dnsmadeeasy": + provider, err = dnsmadeeasy.NewDNSProvider() case "dyn": provider, err = dyn.NewDNSProvider() case "gandi": diff --git a/providers/dns/dnsmadeeasy/dnsmadeeasy.go b/providers/dns/dnsmadeeasy/dnsmadeeasy.go new file mode 100644 index 00000000..c4363a4e --- /dev/null +++ b/providers/dns/dnsmadeeasy/dnsmadeeasy.go @@ -0,0 +1,248 @@ +package dnsmadeeasy + +import ( + "bytes" + "crypto/hmac" + "crypto/sha1" + "crypto/tls" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/xenolf/lego/acme" +) + +// DNSProvider is an implementation of the acme.ChallengeProvider interface that uses +// DNSMadeEasy's DNS API to manage TXT records for a domain. +type DNSProvider struct { + baseURL string + apiKey string + apiSecret string +} + +// Domain holds the DNSMadeEasy API representation of a Domain +type Domain struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// Record holds the DNSMadeEasy API representation of a Domain Record +type Record struct { + ID int `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Value string `json:"value"` + TTL int `json:"ttl"` + SourceID int `json:"sourceId"` +} + +// NewDNSProvider returns a DNSProvider instance configured for DNSMadeEasy DNS. +// Credentials must be passed in the environment variables: DNSMADEEASY_API_KEY +// and DNSMADEEASY_API_SECRET. +func NewDNSProvider() (*DNSProvider, error) { + dnsmadeeasyAPIKey := os.Getenv("DNSMADEEASY_API_KEY") + dnsmadeeasyAPISecret := os.Getenv("DNSMADEEASY_API_SECRET") + dnsmadeeasySandbox := os.Getenv("DNSMADEEASY_SANDBOX") + + var baseURL string + + sandbox, _ := strconv.ParseBool(dnsmadeeasySandbox) + if sandbox { + baseURL = "https://api.sandbox.dnsmadeeasy.com/V2.0" + } else { + baseURL = "https://api.dnsmadeeasy.com/V2.0" + } + + return NewDNSProviderCredentials(baseURL, dnsmadeeasyAPIKey, dnsmadeeasyAPISecret) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for DNSMadeEasy. +func NewDNSProviderCredentials(baseURL, apiKey, apiSecret string) (*DNSProvider, error) { + if baseURL == "" || apiKey == "" || apiSecret == "" { + return nil, fmt.Errorf("DNS Made Easy credentials missing") + } + + return &DNSProvider{ + baseURL: baseURL, + apiKey: apiKey, + apiSecret: apiSecret, + }, nil +} + +// Present creates a TXT record using the specified parameters +func (d *DNSProvider) Present(domainName, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domainName, keyAuth) + + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return err + } + + // fetch the domain details + domain, err := d.getDomain(authZone) + if err != nil { + return err + } + + // create the TXT record + name := strings.Replace(fqdn, "."+authZone, "", 1) + record := &Record{Type: "TXT", Name: name, Value: value, TTL: ttl} + + err = d.createRecord(domain, record) + if err != nil { + return err + } + + return nil +} + +// CleanUp removes the TXT records matching the specified parameters +func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error { + fqdn, _, _ := acme.DNS01Record(domainName, keyAuth) + + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return err + } + + // fetch the domain details + domain, err := d.getDomain(authZone) + if err != nil { + return err + } + + // find matching records + name := strings.Replace(fqdn, "."+authZone, "", 1) + records, err := d.getRecords(domain, name, "TXT") + if err != nil { + return err + } + + // delete records + for _, record := range *records { + err = d.deleteRecord(record) + if err != nil { + return err + } + } + + return nil +} + +func (d *DNSProvider) getDomain(authZone string) (*Domain, error) { + domainName := authZone[0 : len(authZone)-1] + resource := fmt.Sprintf("%s%s", "/dns/managed/name?domainname=", domainName) + + resp, err := d.sendRequest("GET", resource, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + domain := &Domain{} + err = json.NewDecoder(resp.Body).Decode(&domain) + if err != nil { + return nil, err + } + + return domain, nil +} + +func (d *DNSProvider) getRecords(domain *Domain, recordName, recordType string) (*[]Record, error) { + resource := fmt.Sprintf("%s/%d/%s%s%s%s", "/dns/managed", domain.ID, "records?recordName=", recordName, "&type=", recordType) + + resp, err := d.sendRequest("GET", resource, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + type recordsResponse struct { + Records *[]Record `json:"data"` + } + + records := &recordsResponse{} + err = json.NewDecoder(resp.Body).Decode(&records) + if err != nil { + return nil, err + } + + return records.Records, nil +} + +func (d *DNSProvider) createRecord(domain *Domain, record *Record) error { + url := fmt.Sprintf("%s/%d/%s", "/dns/managed", domain.ID, "records") + + resp, err := d.sendRequest("POST", url, record) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +func (d *DNSProvider) deleteRecord(record Record) error { + resource := fmt.Sprintf("%s/%d/%s/%d", "/dns/managed", record.SourceID, "records", record.ID) + + resp, err := d.sendRequest("DELETE", resource, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) (*http.Response, error) { + url := fmt.Sprintf("%s%s", d.baseURL, resource) + + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + timestamp := time.Now().UTC().Format(time.RFC1123) + signature := computeHMAC(timestamp, d.apiSecret) + + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("x-dnsme-apiKey", d.apiKey) + req.Header.Set("x-dnsme-requestDate", timestamp) + req.Header.Set("x-dnsme-hmac", signature) + req.Header.Set("accept", "application/json") + req.Header.Set("content-type", "application/json") + + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client := &http.Client{ + Transport: transport, + Timeout: time.Duration(10 * time.Second), + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode > 299 { + return nil, fmt.Errorf("DNSMadeEasy API request failed with HTTP status code %d", resp.StatusCode) + } + + return resp, nil +} + +func computeHMAC(message string, secret string) string { + key := []byte(secret) + h := hmac.New(sha1.New, key) + h.Write([]byte(message)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go b/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go new file mode 100644 index 00000000..e860ecb6 --- /dev/null +++ b/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go @@ -0,0 +1,37 @@ +package dnsmadeeasy + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + testLive bool + testAPIKey string + testAPISecret string + testDomain string +) + +func init() { + testAPIKey = os.Getenv("DNSMADEEASY_API_KEY") + testAPISecret = os.Getenv("DNSMADEEASY_API_SECRET") + testDomain = os.Getenv("DNSMADEEASY_DOMAIN") + os.Setenv("DNSMADEEASY_SANDBOX", "true") + testLive = len(testAPIKey) > 0 && len(testAPISecret) > 0 +} + +func TestPresentAndCleanup(t *testing.T) { + if !testLive { + t.Skip("skipping live test") + } + + provider, err := NewDNSProvider() + + err = provider.Present(testDomain, "", "123d==") + assert.NoError(t, err) + + err = provider.CleanUp(testDomain, "", "123d==") + assert.NoError(t, err) +}