From b42b256d5cb099ad0cd72d79782d856f3537e98d Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Tue, 26 Jan 2016 17:57:55 -0700 Subject: [PATCH] Add DigitalOcean DNS provider Also a few vet/lint fixes and improved some error messages --- acme/dns_challenge_digitalocean.go | 136 ++++++++++++++++++++++++ acme/dns_challenge_digitalocean_test.go | 117 ++++++++++++++++++++ acme/dns_challenge_rfc2136_test.go | 5 +- acme/http.go | 2 +- acme/http_challenge.go | 4 +- acme/tls_sni_challenge.go | 4 +- 6 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 acme/dns_challenge_digitalocean.go create mode 100644 acme/dns_challenge_digitalocean_test.go diff --git a/acme/dns_challenge_digitalocean.go b/acme/dns_challenge_digitalocean.go new file mode 100644 index 00000000..7d2ee840 --- /dev/null +++ b/acme/dns_challenge_digitalocean.go @@ -0,0 +1,136 @@ +package acme + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "sync" +) + +// DNSProviderDigitalOcean is an implementation of the DNSProvider interface +// that uses DigitalOcean's REST API to manage TXT records for a domain. +type DNSProviderDigitalOcean struct { + apiAuthToken string + recordIDs map[string]int + recordIDsMu sync.Mutex +} + +// NewDNSProviderDigitalOcean returns a new DNSProviderDigitalOcean instance. +// apiAuthToken is the personal access token created in the DigitalOcean account +// control panel, and it will be sent in bearer authorization headers. +func NewDNSProviderDigitalOcean(apiAuthToken string) (*DNSProviderDigitalOcean, error) { + return &DNSProviderDigitalOcean{ + apiAuthToken: apiAuthToken, + recordIDs: make(map[string]int), + }, nil +} + +// Present creates a TXT record using the specified parameters +func (d *DNSProviderDigitalOcean) Present(domain, token, keyAuth string) error { + // txtRecordRequest represents the request body to DO's API to make a TXT record + type txtRecordRequest struct { + RecordType string `json:"type"` + Name string `json:"name"` + Data string `json:"data"` + } + + // txtRecordResponse represents a response from DO's API after making a TXT record + type txtRecordResponse struct { + DomainRecord struct { + ID int `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Data string `json:"data"` + } `json:"domain_record"` + } + + fqdn, value, _ := DNS01Record(domain, keyAuth) + + reqURL := fmt.Sprintf("%s/v2/domains/%s/records", digitalOceanBaseURL, domain) + reqData := txtRecordRequest{RecordType: "TXT", Name: fqdn, Data: value} + body, err := json.Marshal(reqData) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", reqURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.apiAuthToken)) + + var client http.Client + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + var errInfo digitalOceanAPIError + json.NewDecoder(resp.Body).Decode(&errInfo) + return fmt.Errorf("HTTP %d: %s: %s", resp.StatusCode, errInfo.ID, errInfo.Message) + } + + // Everything looks good; but we'll need the ID later to delete the record + var respData txtRecordResponse + err = json.NewDecoder(resp.Body).Decode(&respData) + if err != nil { + return err + } + d.recordIDsMu.Lock() + d.recordIDs[fqdn] = respData.DomainRecord.ID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters +func (d *DNSProviderDigitalOcean) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := DNS01Record(domain, keyAuth) + + // get the record's unique ID from when we created it + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[fqdn] + d.recordIDsMu.Unlock() + if !ok { + return fmt.Errorf("unknown record ID for '%s'", fqdn) + } + + reqURL := fmt.Sprintf("%s/v2/domains/%s/records/%d", digitalOceanBaseURL, domain, recordID) + req, err := http.NewRequest("DELETE", reqURL, nil) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.apiAuthToken)) + + var client http.Client + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + var errInfo digitalOceanAPIError + json.NewDecoder(resp.Body).Decode(&errInfo) + return fmt.Errorf("HTTP %d: %s: %s", resp.StatusCode, errInfo.ID, errInfo.Message) + } + + // Delete record ID from map + d.recordIDsMu.Lock() + delete(d.recordIDs, fqdn) + d.recordIDsMu.Unlock() + + return nil +} + +type digitalOceanAPIError struct { + ID string `json:"id"` + Message string `json:"message"` +} + +var digitalOceanBaseURL = "https://api.digitalocean.com" diff --git a/acme/dns_challenge_digitalocean_test.go b/acme/dns_challenge_digitalocean_test.go new file mode 100644 index 00000000..cf62090e --- /dev/null +++ b/acme/dns_challenge_digitalocean_test.go @@ -0,0 +1,117 @@ +package acme + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" +) + +var fakeDigitalOceanAuth = "asdf1234" + +func TestDigitalOceanPresent(t *testing.T) { + var requestReceived bool + + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestReceived = true + + if got, want := r.Method, "POST"; got != want { + t.Errorf("Expected method to be '%s' but got '%s'", want, got) + } + if got, want := r.URL.Path, "/v2/domains/example.com/records"; got != want { + t.Errorf("Expected path to be '%s' but got '%s'", want, got) + } + if got, want := r.Header.Get("Content-Type"), "application/json"; got != want { + t.Errorf("Expected Content-Type to be '%s' but got '%s'", want, got) + } + if got, want := r.Header.Get("Authorization"), "Bearer asdf1234"; got != want { + t.Errorf("Expected Authorization to be '%s' but got '%s'", want, got) + } + + reqBody, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatalf("Error reading request body: %v", err) + } + if got, want := string(reqBody), `{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI"}`; got != want { + t.Errorf("Expected body data to be: `%s` but got `%s`", want, got) + } + + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{ + "domain_record": { + "id": 1234567, + "type": "TXT", + "name": "_acme-challenge", + "data": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", + "priority": null, + "port": null, + "weight": null + } + }`) + })) + defer mock.Close() + digitalOceanBaseURL = mock.URL + + doprov, err := NewDNSProviderDigitalOcean(fakeDigitalOceanAuth) + if doprov == nil { + t.Fatal("Expected non-nil DigitalOcean provider, but was nil") + } + if err != nil { + t.Fatalf("Expected no error creating provider, but got: %v", err) + } + + err = doprov.Present("example.com", "", "foobar") + if err != nil { + t.Fatalf("Expected no error creating TXT record, but got: %v", err) + } + if !requestReceived { + t.Error("Expected request to be received by mock backend, but it wasn't") + } +} + +func TestDigitalOceanCleanUp(t *testing.T) { + var requestReceived bool + + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestReceived = true + + if got, want := r.Method, "DELETE"; got != want { + t.Errorf("Expected method to be '%s' but got '%s'", want, got) + } + if got, want := r.URL.Path, "/v2/domains/example.com/records/1234567"; got != want { + t.Errorf("Expected path to be '%s' but got '%s'", want, got) + } + // NOTE: Even though the body is empty, DigitalOcean API docs still show setting this Content-Type... + if got, want := r.Header.Get("Content-Type"), "application/json"; got != want { + t.Errorf("Expected Content-Type to be '%s' but got '%s'", want, got) + } + if got, want := r.Header.Get("Authorization"), "Bearer asdf1234"; got != want { + t.Errorf("Expected Authorization to be '%s' but got '%s'", want, got) + } + + w.WriteHeader(http.StatusNoContent) + })) + defer mock.Close() + digitalOceanBaseURL = mock.URL + + doprov, err := NewDNSProviderDigitalOcean(fakeDigitalOceanAuth) + if doprov == nil { + t.Fatal("Expected non-nil DigitalOcean provider, but was nil") + } + if err != nil { + t.Fatalf("Expected no error creating provider, but got: %v", err) + } + + doprov.recordIDsMu.Lock() + doprov.recordIDs["_acme-challenge.example.com."] = 1234567 + doprov.recordIDsMu.Unlock() + + err = doprov.CleanUp("example.com", "", "") + if err != nil { + t.Fatalf("Expected no error removing TXT record, but got: %v", err) + } + if !requestReceived { + t.Error("Expected request to be received by mock backend, but it wasn't") + } +} diff --git a/acme/dns_challenge_rfc2136_test.go b/acme/dns_challenge_rfc2136_test.go index 7e071a7f..f9fc5dea 100644 --- a/acme/dns_challenge_rfc2136_test.go +++ b/acme/dns_challenge_rfc2136_test.go @@ -2,12 +2,13 @@ package acme import ( "bytes" - "github.com/miekg/dns" "net" "strings" "sync" "testing" "time" + + "github.com/miekg/dns" ) var ( @@ -38,7 +39,7 @@ func TestRFC2136CanaryLocalTestServer(t *testing.T) { m.SetQuestion("example.com.", dns.TypeTXT) r, _, err := c.Exchange(m, addrstr) if err != nil || len(r.Extra) == 0 { - t.Fatalf("Failed to communicate with test server:", err) + t.Fatalf("Failed to communicate with test server: %v", err) } txt := r.Extra[0].(*dns.TXT).Txt[0] if txt != "Hello world" { diff --git a/acme/http.go b/acme/http.go index 661a0588..3272840e 100644 --- a/acme/http.go +++ b/acme/http.go @@ -10,7 +10,7 @@ import ( "strings" ) -// UserAgent, if non-empty, will be tacked onto the User-Agent string in requests. +// UserAgent (if non-empty) will be tacked onto the User-Agent string in requests. var UserAgent string const ( diff --git a/acme/http_challenge.go b/acme/http_challenge.go index b1e96269..a9f8e5cf 100644 --- a/acme/http_challenge.go +++ b/acme/http_challenge.go @@ -32,12 +32,12 @@ func (s *httpChallenge) Solve(chlng challenge, domain string) error { err = s.provider.Present(domain, chlng.Token, keyAuth) if err != nil { - return fmt.Errorf("Error presenting token %s", err) + return fmt.Errorf("[%s] error presenting token: %v", domain, err) } defer func() { err := s.provider.CleanUp(domain, chlng.Token, keyAuth) if err != nil { - log.Printf("Error cleaning up %s %v ", domain, err) + log.Printf("[%s] error cleaning up: %v", domain, err) } }() diff --git a/acme/tls_sni_challenge.go b/acme/tls_sni_challenge.go index 3c96ea67..2ab3abd0 100644 --- a/acme/tls_sni_challenge.go +++ b/acme/tls_sni_challenge.go @@ -33,12 +33,12 @@ func (t *tlsSNIChallenge) Solve(chlng challenge, domain string) error { err = t.provider.Present(domain, chlng.Token, keyAuth) if err != nil { - return fmt.Errorf("Error presenting token %s", err) + return fmt.Errorf("[%s] error presenting token: %v", domain, err) } defer func() { err := t.provider.CleanUp(domain, chlng.Token, keyAuth) if err != nil { - log.Printf("Error cleaning up %s %v ", domain, err) + log.Printf("[%s] error cleaning up: %v", domain, err) } }() return t.validate(t.jws, domain, chlng.URI, challenge{Resource: "challenge", Type: chlng.Type, Token: chlng.Token, KeyAuthorization: keyAuth})