From b90b31980ad28c1ca7d4cd81a83d7310be87c75e Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Sun, 7 Feb 2016 02:10:36 -0500 Subject: [PATCH 1/4] Add Dyn DNS provider This commit adds support for Dyn, specifically Dyn Managed DNS. Makes use of the Dyn REST API [1] and requires 3 environment variables for credentails: DYN_CUSTOMER_NAME DYN_USER_NAME DYN_PASSWORD The Dyn DNS API requires a session to be established first using the credentials above, After creating a session an api token is used for subsequent requests. The unit test performs a "live test" and currently requires a valid Dyn account and domain. [1] https://help.dyn.com/rest/ --- README.md | 1 + acme/dns_challenge_dyn.go | 240 +++++++++++++++++++++++++++++++++ acme/dns_challenge_dyn_test.go | 53 ++++++++ cli_handlers.go | 6 + 4 files changed, 300 insertions(+) create mode 100644 acme/dns_challenge_dyn.go create mode 100644 acme/dns_challenge_dyn_test.go 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/acme/dns_challenge_dyn.go b/acme/dns_challenge_dyn.go new file mode 100644 index 00000000..8210fb09 --- /dev/null +++ b/acme/dns_challenge_dyn.go @@ -0,0 +1,240 @@ +package acme + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strconv" +) + +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"` +} + +// DNSProviderDyn is an implementation of the DNSProvider interface that uses +// Dyn's Managed DNS API to manage TXT records for a domain. +type DNSProviderDyn struct { + customerName string + userName string + password string + token string +} + +// NewDNSProviderDyn returns a new DNSProviderDyn instance. customerName is +// the customer name of the Dyn account. userName is the user name. password is +// the password. +func NewDNSProviderDyn(customerName, userName, password string) (*DNSProviderDyn, error) { + return &DNSProviderDyn{ + customerName: customerName, + userName: userName, + password: password, + }, nil +} + +func (d *DNSProviderDyn) 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) + } + + resp, err := http.DefaultClient.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 *DNSProviderDyn) 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 *DNSProviderDyn) 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) + resp, err := http.DefaultClient.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 *DNSProviderDyn) Present(domain, token, keyAuth string) error { + err := d.login() + if err != nil { + return err + } + + fqdn, value, ttl := 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 *DNSProviderDyn) 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 *DNSProviderDyn) CleanUp(domain, token, keyAuth string) error { + err := d.login() + if err != nil { + return err + } + + fqdn, _, _ := 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) + resp, err := http.DefaultClient.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/acme/dns_challenge_dyn_test.go b/acme/dns_challenge_dyn_test.go new file mode 100644 index 00000000..06a8628f --- /dev/null +++ b/acme/dns_challenge_dyn_test.go @@ -0,0 +1,53 @@ +package acme + +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 := NewDNSProviderDyn(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 := NewDNSProviderDyn(dynCustomerName, dynUserName, dynPassword) + assert.NoError(t, err) + + err = provider.CleanUp(dynDomain, "", "123d==") + assert.NoError(t, err) +} diff --git a/cli_handlers.go b/cli_handlers.go index eee7508d..003d0db9 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -109,6 +109,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 = acme.NewDNSProviderDyn(dynCustomerName, dynUserName, dynPassword) case "manual": provider, err = acme.NewDNSProviderManual() } From 1de97a90da1d464ede4b022a760b156ea7d0ca9e Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Thu, 17 Mar 2016 22:30:21 -0400 Subject: [PATCH 2/4] Move dyn provider out of acme package per #144 --- cli.go | 1 + cli_handlers.go | 3 +- .../dns/dyn/dyn.go | 31 ++++++++++--------- .../dns/dyn/dyn_test.go | 6 ++-- 4 files changed, 23 insertions(+), 18 deletions(-) rename acme/dns_challenge_dyn.go => providers/dns/dyn/dyn.go (84%) rename acme/dns_challenge_dyn_test.go => providers/dns/dyn/dyn_test.go (84%) diff --git a/cli.go b/cli.go index ecf1c500..6771ba6b 100644 --- a/cli.go +++ b/cli.go @@ -167,6 +167,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 003d0db9..4e5c3ee2 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -18,6 +18,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" ) @@ -114,7 +115,7 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { dynUserName := os.Getenv("DYN_USER_NAME") dynPassword := os.Getenv("DYN_PASSWORD") - provider, err = acme.NewDNSProviderDyn(dynCustomerName, dynUserName, dynPassword) + provider, err = dyn.NewDNSProvider(dynCustomerName, dynUserName, dynPassword) case "manual": provider, err = acme.NewDNSProviderManual() } diff --git a/acme/dns_challenge_dyn.go b/providers/dns/dyn/dyn.go similarity index 84% rename from acme/dns_challenge_dyn.go rename to providers/dns/dyn/dyn.go index 8210fb09..e13eb3c7 100644 --- a/acme/dns_challenge_dyn.go +++ b/providers/dns/dyn/dyn.go @@ -1,4 +1,5 @@ -package acme +// Package dyn implements a DNS provider for solving the DNS-01 challenge using Dyn Managed DNS. +package dyn import ( "bytes" @@ -6,6 +7,8 @@ import ( "fmt" "net/http" "strconv" + + "github.com/xenolf/lego/acme" ) var dynBaseURL = "https://api.dynect.net/REST" @@ -24,27 +27,27 @@ type DynResponse struct { Messages json.RawMessage `json:"msgs"` } -// DNSProviderDyn is an implementation of the DNSProvider interface that uses +// DNSProvider is an implementation of the acme.ChallengeProvider interface that uses // Dyn's Managed DNS API to manage TXT records for a domain. -type DNSProviderDyn struct { +type DNSProvider struct { customerName string userName string password string token string } -// NewDNSProviderDyn returns a new DNSProviderDyn instance. customerName is +// 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 NewDNSProviderDyn(customerName, userName, password string) (*DNSProviderDyn, error) { - return &DNSProviderDyn{ +func NewDNSProvider(customerName, userName, password string) (*DNSProvider, error) { + return &DNSProvider{ customerName: customerName, userName: userName, password: password, }, nil } -func (d *DNSProviderDyn) sendRequest(method, resource string, payload interface{}) (*DynResponse, error) { +func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) (*DynResponse, error) { url := fmt.Sprintf("%s/%s", dynBaseURL, resource) body, err := json.Marshal(payload) @@ -90,7 +93,7 @@ func (d *DNSProviderDyn) sendRequest(method, resource string, payload interface{ // Starts a new Dyn API Session. Authenticates using customerName, userName, // password and receives a token to be used in for subsequent requests. -func (d *DNSProviderDyn) login() error { +func (d *DNSProvider) login() error { type creds struct { Customer string `json:"customer_name"` User string `json:"user_name"` @@ -120,7 +123,7 @@ func (d *DNSProviderDyn) login() error { } // Destroys Dyn Session -func (d *DNSProviderDyn) logout() error { +func (d *DNSProvider) logout() error { if len(d.token) == 0 { // nothing to do return nil @@ -149,13 +152,13 @@ func (d *DNSProviderDyn) logout() error { } // Present creates a TXT record using the specified parameters -func (d *DNSProviderDyn) Present(domain, token, keyAuth string) error { +func (d *DNSProvider) Present(domain, token, keyAuth string) error { err := d.login() if err != nil { return err } - fqdn, value, ttl := DNS01Record(domain, keyAuth) + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) data := map[string]interface{}{ "rdata": map[string]string{ @@ -183,7 +186,7 @@ func (d *DNSProviderDyn) Present(domain, token, keyAuth string) error { return nil } -func (d *DNSProviderDyn) publish(domain, notes string) error { +func (d *DNSProvider) publish(domain, notes string) error { type publish struct { Publish bool `json:"publish"` Notes string `json:"notes"` @@ -200,13 +203,13 @@ func (d *DNSProviderDyn) publish(domain, notes string) error { } // CleanUp removes the TXT record matching the specified parameters -func (d *DNSProviderDyn) CleanUp(domain, token, keyAuth string) error { +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err := d.login() if err != nil { return err } - fqdn, _, _ := DNS01Record(domain, keyAuth) + fqdn, _, _ := acme.DNS01Record(domain, keyAuth) resource := fmt.Sprintf("TXTRecord/%s/%s/", domain, fqdn) url := fmt.Sprintf("%s/%s", dynBaseURL, resource) diff --git a/acme/dns_challenge_dyn_test.go b/providers/dns/dyn/dyn_test.go similarity index 84% rename from acme/dns_challenge_dyn_test.go rename to providers/dns/dyn/dyn_test.go index 06a8628f..fcc3d63e 100644 --- a/acme/dns_challenge_dyn_test.go +++ b/providers/dns/dyn/dyn_test.go @@ -1,4 +1,4 @@ -package acme +package dyn import ( "os" @@ -31,7 +31,7 @@ func TestLiveDynPresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderDyn(dynCustomerName, dynUserName, dynPassword) + provider, err := NewDNSProvider(dynCustomerName, dynUserName, dynPassword) assert.NoError(t, err) err = provider.Present(dynDomain, "", "123d==") @@ -45,7 +45,7 @@ func TestLiveDynCleanUp(t *testing.T) { time.Sleep(time.Second * 1) - provider, err := NewDNSProviderDyn(dynCustomerName, dynUserName, dynPassword) + provider, err := NewDNSProvider(dynCustomerName, dynUserName, dynPassword) assert.NoError(t, err) err = provider.CleanUp(dynDomain, "", "123d==") From 9f8d5e4076d5ef436b77b569a31d8fe47de420c1 Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Fri, 18 Mar 2016 23:20:58 -0400 Subject: [PATCH 3/4] Add timeout to HTTP client --- providers/dns/dyn/dyn.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/providers/dns/dyn/dyn.go b/providers/dns/dyn/dyn.go index e13eb3c7..a16e68aa 100644 --- a/providers/dns/dyn/dyn.go +++ b/providers/dns/dyn/dyn.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strconv" + "time" "github.com/xenolf/lego/acme" ) @@ -64,7 +65,8 @@ func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) req.Header.Set("Auth-Token", d.token) } - resp, err := http.DefaultClient.Do(req) + client := &http.Client{Timeout: time.Duration(10 * time.Second)} + resp, err := client.Do(req) if err != nil { return nil, err } @@ -136,7 +138,9 @@ func (d *DNSProvider) logout() error { } req.Header.Set("Content-Type", "application/json") req.Header.Set("Auth-Token", d.token) - resp, err := http.DefaultClient.Do(req) + + client := &http.Client{Timeout: time.Duration(10 * time.Second)} + resp, err := client.Do(req) if err != nil { return err } @@ -219,7 +223,9 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } req.Header.Set("Content-Type", "application/json") req.Header.Set("Auth-Token", d.token) - resp, err := http.DefaultClient.Do(req) + + client := &http.Client{Timeout: time.Duration(10 * time.Second)} + resp, err := client.Do(req) if err != nil { return err } From b10c35bba19205faf7f9c7d83f1acea5e5eb1f6d Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Sat, 19 Mar 2016 16:14:23 -0400 Subject: [PATCH 4/4] golint fixes. - dynRequest should be private to dyn package - Fix JobID name --- providers/dns/dyn/dyn.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/providers/dns/dyn/dyn.go b/providers/dns/dyn/dyn.go index a16e68aa..a88a6e7a 100644 --- a/providers/dns/dyn/dyn.go +++ b/providers/dns/dyn/dyn.go @@ -14,7 +14,7 @@ import ( var dynBaseURL = "https://api.dynect.net/REST" -type DynResponse struct { +type dynResponse struct { // One of 'success', 'failure', or 'incomplete' Status string `json:"status"` @@ -22,7 +22,7 @@ type DynResponse struct { Data json.RawMessage `json:"data"` // The ID of the job that was created in response to a request. - JobId int `json:"job_id"` + JobID int `json:"job_id"` // A list of zero or more messages Messages json.RawMessage `json:"msgs"` @@ -48,7 +48,7 @@ func NewDNSProvider(customerName, userName, password string) (*DNSProvider, erro }, nil } -func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) (*DynResponse, error) { +func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) (*dynResponse, error) { url := fmt.Sprintf("%s/%s", dynBaseURL, resource) body, err := json.Marshal(payload) @@ -79,7 +79,7 @@ func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) return nil, fmt.Errorf("Dyn API request returned HTTP 307. This is currently unsupported") } - var dynRes DynResponse + var dynRes dynResponse err = json.NewDecoder(resp.Body).Decode(&dynRes) if err != nil { return nil, err