diff --git a/README.md b/README.md index 73acc99f..9fdb69cd 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ GLOBAL OPTIONS: namecheap: NAMECHEAP_API_USER, NAMECHEAP_API_KEY route53: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION rfc2136: RFC2136_TSIG_KEY, RFC2136_TSIG_SECRET, RFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER + dyn: DYN_CUSTOMER_NAME, DYN_USER_NAME, DYN_PASSWORD manual: none --help, -h show help --version, -v print the version diff --git a/cli.go b/cli.go index 77fce0c6..fad27489 100644 --- a/cli.go +++ b/cli.go @@ -168,6 +168,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_API_USER, NAMECHEAP_API_KEY") fmt.Fprintln(w, "\trfc2136:\tRFC2136_TSIG_KEY, RFC2136_TSIG_SECRET,\n\t\tRFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER") fmt.Fprintln(w, "\troute53:\tAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION") + fmt.Fprintln(w, "\tdyn:\tDYN_CUSTOMER_NAME, DYN_USER_NAME, DYN_PASSWORD") w.Flush() fmt.Println(` diff --git a/cli_handlers.go b/cli_handlers.go index a178816f..916ccabe 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -19,6 +19,7 @@ import ( "github.com/xenolf/lego/providers/dns/namecheap" "github.com/xenolf/lego/providers/dns/rfc2136" "github.com/xenolf/lego/providers/dns/route53" + "github.com/xenolf/lego/providers/dns/dyn" "github.com/xenolf/lego/providers/http/webroot" ) @@ -112,6 +113,12 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { tsigSecret := os.Getenv("RFC2136_TSIG_SECRET") provider, err = rfc2136.NewDNSProvider(nameserver, tsigAlgorithm, tsigKey, tsigSecret) + case "dyn": + dynCustomerName := os.Getenv("DYN_CUSTOMER_NAME") + dynUserName := os.Getenv("DYN_USER_NAME") + dynPassword := os.Getenv("DYN_PASSWORD") + + provider, err = dyn.NewDNSProvider(dynCustomerName, dynUserName, dynPassword) case "manual": provider, err = acme.NewDNSProviderManual() } diff --git a/providers/dns/dyn/dyn.go b/providers/dns/dyn/dyn.go new file mode 100644 index 00000000..a88a6e7a --- /dev/null +++ b/providers/dns/dyn/dyn.go @@ -0,0 +1,249 @@ +// Package dyn implements a DNS provider for solving the DNS-01 challenge using Dyn Managed DNS. +package dyn + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/xenolf/lego/acme" +) + +var dynBaseURL = "https://api.dynect.net/REST" + +type dynResponse struct { + // One of 'success', 'failure', or 'incomplete' + Status string `json:"status"` + + // The structure containing the actual results of the request + Data json.RawMessage `json:"data"` + + // The ID of the job that was created in response to a request. + JobID int `json:"job_id"` + + // A list of zero or more messages + Messages json.RawMessage `json:"msgs"` +} + +// DNSProvider is an implementation of the acme.ChallengeProvider interface that uses +// Dyn's Managed DNS API to manage TXT records for a domain. +type DNSProvider struct { + customerName string + userName string + password string + token string +} + +// NewDNSProvider returns a new DNSProvider instance. customerName is +// the customer name of the Dyn account. userName is the user name. password is +// the password. +func NewDNSProvider(customerName, userName, password string) (*DNSProvider, error) { + return &DNSProvider{ + customerName: customerName, + userName: userName, + password: password, + }, nil +} + +func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) (*dynResponse, error) { + url := fmt.Sprintf("%s/%s", dynBaseURL, resource) + + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if len(d.token) > 0 { + req.Header.Set("Auth-Token", d.token) + } + + client := &http.Client{Timeout: time.Duration(10 * time.Second)} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("Dyn API request failed with HTTP status code %d", resp.StatusCode) + } else if resp.StatusCode == 307 { + // TODO add support for HTTP 307 response and long running jobs + return nil, fmt.Errorf("Dyn API request returned HTTP 307. This is currently unsupported") + } + + var dynRes dynResponse + err = json.NewDecoder(resp.Body).Decode(&dynRes) + if err != nil { + return nil, err + } + + if dynRes.Status == "failure" { + // TODO add better error handling + return nil, fmt.Errorf("Dyn API request failed: %s", dynRes.Messages) + } + + return &dynRes, nil +} + +// Starts a new Dyn API Session. Authenticates using customerName, userName, +// password and receives a token to be used in for subsequent requests. +func (d *DNSProvider) login() error { + type creds struct { + Customer string `json:"customer_name"` + User string `json:"user_name"` + Pass string `json:"password"` + } + + type session struct { + Token string `json:"token"` + Version string `json:"version"` + } + + payload := &creds{Customer: d.customerName, User: d.userName, Pass: d.password} + dynRes, err := d.sendRequest("POST", "Session", payload) + if err != nil { + return err + } + + var s session + err = json.Unmarshal(dynRes.Data, &s) + if err != nil { + return err + } + + d.token = s.Token + + return nil +} + +// Destroys Dyn Session +func (d *DNSProvider) logout() error { + if len(d.token) == 0 { + // nothing to do + return nil + } + + url := fmt.Sprintf("%s/Session", dynBaseURL) + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Auth-Token", d.token) + + client := &http.Client{Timeout: time.Duration(10 * time.Second)} + resp, err := client.Do(req) + if err != nil { + return err + } + resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("Dyn API request failed to delete session with HTTP status code %d", resp.StatusCode) + } + + d.token = "" + + return nil +} + +// Present creates a TXT record using the specified parameters +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + err := d.login() + if err != nil { + return err + } + + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + + data := map[string]interface{}{ + "rdata": map[string]string{ + "txtdata": value, + }, + "ttl": strconv.Itoa(ttl), + } + + resource := fmt.Sprintf("TXTRecord/%s/%s/", domain, fqdn) + _, err = d.sendRequest("POST", resource, data) + if err != nil { + return err + } + + err = d.publish(domain, "Added TXT record for ACME dns-01 challenge using lego client") + if err != nil { + return err + } + + err = d.logout() + if err != nil { + return err + } + + return nil +} + +func (d *DNSProvider) publish(domain, notes string) error { + type publish struct { + Publish bool `json:"publish"` + Notes string `json:"notes"` + } + + pub := &publish{Publish: true, Notes: notes} + resource := fmt.Sprintf("Zone/%s/", domain) + _, err := d.sendRequest("PUT", resource, pub) + if err != nil { + return err + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + err := d.login() + if err != nil { + return err + } + + fqdn, _, _ := acme.DNS01Record(domain, keyAuth) + + resource := fmt.Sprintf("TXTRecord/%s/%s/", domain, fqdn) + url := fmt.Sprintf("%s/%s", dynBaseURL, resource) + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Auth-Token", d.token) + + client := &http.Client{Timeout: time.Duration(10 * time.Second)} + resp, err := client.Do(req) + if err != nil { + return err + } + resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("Dyn API request failed to delete TXT record HTTP status code %d", resp.StatusCode) + } + + err = d.publish(domain, "Removed TXT record for ACME dns-01 challenge using lego client") + if err != nil { + return err + } + + err = d.logout() + if err != nil { + return err + } + + return nil +} diff --git a/providers/dns/dyn/dyn_test.go b/providers/dns/dyn/dyn_test.go new file mode 100644 index 00000000..fcc3d63e --- /dev/null +++ b/providers/dns/dyn/dyn_test.go @@ -0,0 +1,53 @@ +package dyn + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + dynLiveTest bool + dynCustomerName string + dynUserName string + dynPassword string + dynDomain string +) + +func init() { + dynCustomerName = os.Getenv("DYN_CUSTOMER_NAME") + dynUserName = os.Getenv("DYN_USER_NAME") + dynPassword = os.Getenv("DYN_PASSWORD") + dynDomain = os.Getenv("DYN_DOMAIN") + if len(dynCustomerName) > 0 && len(dynUserName) > 0 && len(dynPassword) > 0 && len(dynDomain) > 0 { + dynLiveTest = true + } +} + +func TestLiveDynPresent(t *testing.T) { + if !dynLiveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProvider(dynCustomerName, dynUserName, dynPassword) + assert.NoError(t, err) + + err = provider.Present(dynDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestLiveDynCleanUp(t *testing.T) { + if !dynLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 1) + + provider, err := NewDNSProvider(dynCustomerName, dynUserName, dynPassword) + assert.NoError(t, err) + + err = provider.CleanUp(dynDomain, "", "123d==") + assert.NoError(t, err) +}