From 800538520ebdfdf123266c1fbf599ccb0af38fce Mon Sep 17 00:00:00 2001 From: Luke Hanley Date: Fri, 4 Nov 2016 09:29:14 +0000 Subject: [PATCH] Adds support for Rackspace DNS (#309) * Working Rackspace Provider * Finalize Rackspace DNS provider Closes out #208. Adds mock API and testing. --- cli.go | 1 + cli_handlers.go | 3 + providers/dns/rackspace/rackspace.go | 284 ++++++++++++++++++++++ providers/dns/rackspace/rackspace_test.go | 220 +++++++++++++++++ 4 files changed, 508 insertions(+) create mode 100644 providers/dns/rackspace/rackspace.go create mode 100644 providers/dns/rackspace/rackspace_test.go diff --git a/cli.go b/cli.go index 6330dc66..f9e414aa 100644 --- a/cli.go +++ b/cli.go @@ -212,6 +212,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tlinode:\tLINODE_API_KEY") fmt.Fprintln(w, "\tmanual:\tnone") fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_API_USER, NAMECHEAP_API_KEY") + fmt.Fprintln(w, "\trackspace:\tRACKSPACE_USER, RACKSPACE_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") diff --git a/cli_handlers.go b/cli_handlers.go index a92faadb..bba0879a 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -29,6 +29,7 @@ import ( "github.com/xenolf/lego/providers/dns/ns1" "github.com/xenolf/lego/providers/dns/ovh" "github.com/xenolf/lego/providers/dns/pdns" + "github.com/xenolf/lego/providers/dns/rackspace" "github.com/xenolf/lego/providers/dns/rfc2136" "github.com/xenolf/lego/providers/dns/route53" "github.com/xenolf/lego/providers/dns/vultr" @@ -157,6 +158,8 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { provider, err = acme.NewDNSProviderManual() case "namecheap": provider, err = namecheap.NewDNSProvider() + case "rackspace": + provider, err = rackspace.NewDNSProvider() case "route53": provider, err = route53.NewDNSProvider() case "rfc2136": diff --git a/providers/dns/rackspace/rackspace.go b/providers/dns/rackspace/rackspace.go new file mode 100644 index 00000000..2b106a27 --- /dev/null +++ b/providers/dns/rackspace/rackspace.go @@ -0,0 +1,284 @@ +// Package rackspace implements a DNS provider for solving the DNS-01 +// challenge using rackspace DNS. +package rackspace + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/xenolf/lego/acme" +) + +// rackspaceAPIURL represents the Identity API endpoint to call +var rackspaceAPIURL = "https://identity.api.rackspacecloud.com/v2.0/tokens" + +// DNSProvider is an implementation of the acme.ChallengeProvider interface +// used to store the reusable token and DNS API endpoint +type DNSProvider struct { + token string + cloudDNSEndpoint string +} + +// NewDNSProvider returns a DNSProvider instance configured for Rackspace. +// Credentials must be passed in the environment variables: RACKSPACE_USER +// and RACKSPACE_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + user := os.Getenv("RACKSPACE_USER") + key := os.Getenv("RACKSPACE_API_KEY") + return NewDNSProviderCredentials(user, key) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for Rackspace. It authenticates against +// the API, also grabbing the DNS Endpoint. +func NewDNSProviderCredentials(user, key string) (*DNSProvider, error) { + if user == "" || key == "" { + return nil, fmt.Errorf("Rackspace credentials missing") + } + + type APIKeyCredentials struct { + Username string `json:"username"` + APIKey string `json:"apiKey"` + } + + type Auth struct { + APIKeyCredentials `json:"RAX-KSKEY:apiKeyCredentials"` + } + + type RackspaceAuthData struct { + Auth `json:"auth"` + } + + type RackspaceIdentity struct { + Access struct { + ServiceCatalog []struct { + Endpoints []struct { + PublicURL string `json:"publicURL"` + TenantID string `json:"tenantId"` + } `json:"endpoints"` + Name string `json:"name"` + } `json:"serviceCatalog"` + Token struct { + ID string `json:"id"` + } `json:"token"` + } `json:"access"` + } + + authData := RackspaceAuthData{ + Auth: Auth{ + APIKeyCredentials: APIKeyCredentials{ + Username: user, + APIKey: key, + }, + }, + } + + body, err := json.Marshal(authData) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", rackspaceAPIURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + client := http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("Error querying Rackspace Identity API: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Rackspace Authentication failed. Response code: %d", resp.StatusCode) + } + + var rackspaceIdentity RackspaceIdentity + err = json.NewDecoder(resp.Body).Decode(&rackspaceIdentity) + if err != nil { + return nil, err + } + + // Iterate through the Service Catalog to get the DNS Endpoint + var dnsEndpoint string + for _, service := range rackspaceIdentity.Access.ServiceCatalog { + if service.Name == "cloudDNS" { + dnsEndpoint = service.Endpoints[0].PublicURL + break + } + } + if dnsEndpoint == "" { + return nil, fmt.Errorf("Failed to populate DNS endpoint, check Rackspace API for changes.") + } + + return &DNSProvider{ + token: rackspaceIdentity.Access.Token.ID, + cloudDNSEndpoint: dnsEndpoint, + }, nil +} + +// Present creates a TXT record to fulfil the dns-01 challenge +func (c *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + zoneID, err := c.getHostedZoneID(fqdn) + if err != nil { + return err + } + + rec := RackspaceRecords{ + RackspaceRecord: []RackspaceRecord{{ + Name: acme.UnFqdn(fqdn), + Type: "TXT", + Data: value, + TTL: 300, + }}, + } + + body, err := json.Marshal(rec) + if err != nil { + return err + } + + _, err = c.makeRequest("POST", fmt.Sprintf("/domains/%d/records", zoneID), bytes.NewReader(body)) + if err != nil { + return err + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters +func (c *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := acme.DNS01Record(domain, keyAuth) + zoneID, err := c.getHostedZoneID(fqdn) + if err != nil { + return err + } + + record, err := c.findTxtRecord(fqdn, zoneID) + if err != nil { + return err + } + + _, err = c.makeRequest("DELETE", fmt.Sprintf("/domains/%d/records?id=%s", zoneID, record.ID), nil) + if err != nil { + return err + } + + return nil +} + +// getHostedZoneID performs a lookup to get the DNS zone which needs +// modifying for a given FQDN +func (c *DNSProvider) getHostedZoneID(fqdn string) (int, error) { + // HostedZones represents the response when querying Rackspace DNS zones + type ZoneSearchResponse struct { + TotalEntries int `json:"totalEntries"` + HostedZones []struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"domains"` + } + + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return 0, err + } + + result, err := c.makeRequest("GET", fmt.Sprintf("/domains?name=%s", acme.UnFqdn(authZone)), nil) + if err != nil { + return 0, err + } + + var zoneSearchResponse ZoneSearchResponse + err = json.Unmarshal(result, &zoneSearchResponse) + if err != nil { + return 0, err + } + + // If nothing was returned, or for whatever reason more than 1 was returned (the search uses exact match, so should not occur) + if zoneSearchResponse.TotalEntries != 1 { + return 0, fmt.Errorf("Found %d zones for %s in Rackspace for domain %s", zoneSearchResponse.TotalEntries, authZone, fqdn) + } + + return zoneSearchResponse.HostedZones[0].ID, nil +} + +// findTxtRecord searches a DNS zone for a TXT record with a specific name +func (c *DNSProvider) findTxtRecord(fqdn string, zoneID int) (*RackspaceRecord, error) { + result, err := c.makeRequest("GET", fmt.Sprintf("/domains/%d/records?type=TXT&name=%s", zoneID, acme.UnFqdn(fqdn)), nil) + if err != nil { + return nil, err + } + + var records RackspaceRecords + err = json.Unmarshal(result, &records) + if err != nil { + return nil, err + } + + recordsLength := len(records.RackspaceRecord) + switch recordsLength { + case 1: + break + case 0: + return nil, fmt.Errorf("No TXT record found for %s", fqdn) + default: + return nil, fmt.Errorf("More than 1 TXT record found for %s", fqdn) + } + + return &records.RackspaceRecord[0], nil +} + +// makeRequest is a wrapper function used for making DNS API requests +func (c *DNSProvider) makeRequest(method, uri string, body io.Reader) (json.RawMessage, error) { + url := c.cloudDNSEndpoint + uri + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + + req.Header.Set("X-Auth-Token", c.token) + req.Header.Set("Content-Type", "application/json") + + client := http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("Error querying DNS API: %v", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + return nil, fmt.Errorf("Request failed for %s %s. Response code: %d", method, url, resp.StatusCode) + } + + var r json.RawMessage + err = json.NewDecoder(resp.Body).Decode(&r) + if err != nil { + return nil, fmt.Errorf("JSON decode failed for %s %s. Response code: %d", method, url, resp.StatusCode) + } + + return r, nil +} + +// RackspaceRecords is the list of records sent/recieved from the DNS API +type RackspaceRecords struct { + RackspaceRecord []RackspaceRecord `json:"records"` +} + +// RackspaceRecord represents a Rackspace DNS record +type RackspaceRecord struct { + Name string `json:"name"` + Type string `json:"type"` + Data string `json:"data"` + TTL int `json:"ttl,omitempty"` + ID string `json:"id,omitempty"` +} diff --git a/providers/dns/rackspace/rackspace_test.go b/providers/dns/rackspace/rackspace_test.go new file mode 100644 index 00000000..22c979ca --- /dev/null +++ b/providers/dns/rackspace/rackspace_test.go @@ -0,0 +1,220 @@ +package rackspace + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + rackspaceLiveTest bool + rackspaceUser string + rackspaceAPIKey string + rackspaceDomain string + testAPIURL string +) + +func init() { + rackspaceUser = os.Getenv("RACKSPACE_USER") + rackspaceAPIKey = os.Getenv("RACKSPACE_API_KEY") + rackspaceDomain = os.Getenv("RACKSPACE_DOMAIN") + if len(rackspaceUser) > 0 && len(rackspaceAPIKey) > 0 && len(rackspaceDomain) > 0 { + rackspaceLiveTest = true + } +} + +func testRackspaceEnv() { + rackspaceAPIURL = testAPIURL + os.Setenv("RACKSPACE_USER", "testUser") + os.Setenv("RACKSPACE_API_KEY", "testKey") +} + +func liveRackspaceEnv() { + rackspaceAPIURL = "https://identity.api.rackspacecloud.com/v2.0/tokens" + os.Setenv("RACKSPACE_USER", rackspaceUser) + os.Setenv("RACKSPACE_API_KEY", rackspaceAPIKey) +} + +func startTestServers() (identityAPI, dnsAPI *httptest.Server) { + dnsAPI = httptest.NewServer(dnsMux()) + dnsEndpoint := dnsAPI.URL + "/123456" + + identityAPI = httptest.NewServer(identityHandler(dnsEndpoint)) + testAPIURL = identityAPI.URL + "/" + return +} + +func closeTestServers(identityAPI, dnsAPI *httptest.Server) { + identityAPI.Close() + dnsAPI.Close() +} + +func identityHandler(dnsEndpoint string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqBody, err := ioutil.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + resp, found := jsonMap[string(reqBody)] + if !found { + w.WriteHeader(http.StatusBadRequest) + return + } + resp = strings.Replace(resp, "https://dns.api.rackspacecloud.com/v1.0/123456", dnsEndpoint, 1) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, resp) + }) +} + +func dnsMux() *http.ServeMux { + mux := http.NewServeMux() + + // Used by `getHostedZoneID()` finding `zoneID` "?name=example.com" + mux.HandleFunc("/123456/domains", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("name") == "example.com" { + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, jsonMap["zoneDetails"]) + return + } + w.WriteHeader(http.StatusBadRequest) + return + }) + + mux.HandleFunc("/123456/domains/112233/records", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + // Used by `Present()` creating the TXT record + case http.MethodPost: + reqBody, err := ioutil.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + resp, found := jsonMap[string(reqBody)] + if !found { + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusAccepted) + fmt.Fprintf(w, resp) + // Used by `findTxtRecord()` finding `record.ID` "?type=TXT&name=_acme-challenge.example.com" + case http.MethodGet: + if r.URL.Query().Get("type") == "TXT" && r.URL.Query().Get("name") == "_acme-challenge.example.com" { + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, jsonMap["recordDetails"]) + return + } + w.WriteHeader(http.StatusBadRequest) + return + // Used by `CleanUp()` deleting the TXT record "?id=445566" + case http.MethodDelete: + if r.URL.Query().Get("id") == "TXT-654321" { + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, jsonMap["recordDelete"]) + return + } + w.WriteHeader(http.StatusBadRequest) + } + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Printf("Not Found for Request: (%+v)\n\n", r) + }) + + return mux +} + +func TestNewDNSProviderMissingCredErr(t *testing.T) { + testRackspaceEnv() + _, err := NewDNSProviderCredentials("", "") + assert.EqualError(t, err, "Rackspace credentials missing") +} + +func TestOfflineRackspaceValid(t *testing.T) { + testRackspaceEnv() + provider, err := NewDNSProviderCredentials(os.Getenv("RACKSPACE_USER"), os.Getenv("RACKSPACE_API_KEY")) + + assert.NoError(t, err) + assert.Equal(t, provider.token, "testToken", "The token should match") +} + +func TestOfflineRackspacePresent(t *testing.T) { + testRackspaceEnv() + provider, err := NewDNSProvider() + + if assert.NoError(t, err) { + err = provider.Present("example.com", "token", "keyAuth") + assert.NoError(t, err) + } +} + +func TestOfflineRackspaceCleanUp(t *testing.T) { + testRackspaceEnv() + provider, err := NewDNSProvider() + + if assert.NoError(t, err) { + err = provider.CleanUp("example.com", "token", "keyAuth") + assert.NoError(t, err) + } +} + +func TestNewDNSProviderValidEnv(t *testing.T) { + if !rackspaceLiveTest { + t.Skip("skipping live test") + } + + liveRackspaceEnv() + provider, err := NewDNSProvider() + assert.NoError(t, err) + assert.Contains(t, provider.cloudDNSEndpoint, "https://dns.api.rackspacecloud.com/v1.0/", "The endpoint URL should contain the base") +} + +func TestRackspacePresent(t *testing.T) { + if !rackspaceLiveTest { + t.Skip("skipping live test") + } + + liveRackspaceEnv() + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.Present(rackspaceDomain, "", "112233445566==") + assert.NoError(t, err) +} + +func TestRackspaceCleanUp(t *testing.T) { + if !rackspaceLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 15) + + liveRackspaceEnv() + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.CleanUp(rackspaceDomain, "", "112233445566==") + assert.NoError(t, err) +} + +func TestMain(m *testing.M) { + identityAPI, dnsAPI := startTestServers() + defer closeTestServers(identityAPI, dnsAPI) + os.Exit(m.Run()) +} + +var jsonMap = map[string]string{ + `{"auth":{"RAX-KSKEY:apiKeyCredentials":{"username":"testUser","apiKey":"testKey"}}}`: `{"access":{"token":{"id":"testToken","expires":"1970-01-01T00:00:00.000Z","tenant":{"id":"123456","name":"123456"},"RAX-AUTH:authenticatedBy":["APIKEY"]},"serviceCatalog":[{"type":"rax:dns","endpoints":[{"publicURL":"https://dns.api.rackspacecloud.com/v1.0/123456","tenantId":"123456"}],"name":"cloudDNS"}],"user":{"id":"fakeUseID","name":"testUser"}}}`, + "zoneDetails": `{"domains":[{"name":"example.com","id":112233,"emailAddress":"hostmaster@example.com","updated":"1970-01-01T00:00:00.000+0000","created":"1970-01-01T00:00:00.000+0000"}],"totalEntries":1}`, + `{"records":[{"name":"_acme-challenge.example.com","type":"TXT","data":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM","ttl":300}]}`: `{"request":"{\"records\":[{\"name\":\"_acme-challenge.example.com\",\"type\":\"TXT\",\"data\":\"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM\",\"ttl\":300}]}","status":"RUNNING","verb":"POST","jobId":"00000000-0000-0000-0000-0000000000","callbackUrl":"https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000","requestUrl":"https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/records"}`, + "recordDetails": `{"records":[{"name":"_acme-challenge.example.com","id":"TXT-654321","type":"TXT","data":"pW9ZKG0xz_PCriK-nCMOjADy9eJcgGWIzkkj2fN4uZM","ttl":300,"updated":"1970-01-01T00:00:00.000+0000","created":"1970-01-01T00:00:00.000+0000"}]}`, + "recordDelete": `{"status":"RUNNING","verb":"DELETE","jobId":"00000000-0000-0000-0000-0000000000","callbackUrl":"https://dns.api.rackspacecloud.com/v1.0/123456/status/00000000-0000-0000-0000-0000000000","requestUrl":"https://dns.api.rackspacecloud.com/v1.0/123456/domains/112233/recordsid=TXT-654321"}`, +}