From 8f91c777559748fa6e857d9fc1f4ae079a532813 Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Mon, 3 Oct 2016 16:03:15 -0400 Subject: Adding ability to serve TLS directly from Mattermost server (#4119) --- .../lego/providers/dns/cloudflare/cloudflare.go | 223 +++++ .../providers/dns/cloudflare/cloudflare_test.go | 80 ++ .../providers/dns/digitalocean/digitalocean.go | 166 ++++ .../dns/digitalocean/digitalocean_test.go | 117 +++ .../xenolf/lego/providers/dns/dnsimple/dnsimple.go | 141 ++++ .../lego/providers/dns/dnsimple/dnsimple_test.go | 79 ++ .../lego/providers/dns/dnsmadeeasy/dnsmadeeasy.go | 248 ++++++ .../providers/dns/dnsmadeeasy/dnsmadeeasy_test.go | 37 + .../xenolf/lego/providers/dns/dyn/dyn.go | 274 ++++++ .../xenolf/lego/providers/dns/dyn/dyn_test.go | 53 ++ .../xenolf/lego/providers/dns/gandi/gandi.go | 472 +++++++++++ .../xenolf/lego/providers/dns/gandi/gandi_test.go | 939 +++++++++++++++++++++ .../lego/providers/dns/googlecloud/googlecloud.go | 158 ++++ .../providers/dns/googlecloud/googlecloud_test.go | 85 ++ .../xenolf/lego/providers/dns/linode/linode.go | 131 +++ .../lego/providers/dns/linode/linode_test.go | 317 +++++++ .../lego/providers/dns/namecheap/namecheap.go | 416 +++++++++ .../lego/providers/dns/namecheap/namecheap_test.go | 402 +++++++++ .../xenolf/lego/providers/dns/ovh/ovh.go | 159 ++++ .../xenolf/lego/providers/dns/ovh/ovh_test.go | 103 +++ .../xenolf/lego/providers/dns/pdns/README.md | 7 + .../xenolf/lego/providers/dns/pdns/pdns.go | 343 ++++++++ .../xenolf/lego/providers/dns/pdns/pdns_test.go | 80 ++ .../xenolf/lego/providers/dns/rfc2136/rfc2136.go | 129 +++ .../lego/providers/dns/rfc2136/rfc2136_test.go | 244 ++++++ .../lego/providers/dns/route53/fixtures_test.go | 39 + .../xenolf/lego/providers/dns/route53/route53.go | 171 ++++ .../dns/route53/route53_integration_test.go | 70 ++ .../lego/providers/dns/route53/route53_test.go | 87 ++ .../lego/providers/dns/route53/testutil_test.go | 38 + .../xenolf/lego/providers/dns/vultr/vultr.go | 127 +++ .../xenolf/lego/providers/dns/vultr/vultr_test.go | 65 ++ 32 files changed, 6000 insertions(+) create mode 100644 vendor/github.com/xenolf/lego/providers/dns/cloudflare/cloudflare.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/cloudflare/cloudflare_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/digitalocean/digitalocean.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/digitalocean/digitalocean_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/dnsimple/dnsimple.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/dnsimple/dnsimple_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/dnsmadeeasy/dnsmadeeasy.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/dyn/dyn.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/dyn/dyn_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/gandi/gandi.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/gandi/gandi_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/googlecloud/googlecloud.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/googlecloud/googlecloud_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/linode/linode.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/linode/linode_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/namecheap/namecheap.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/namecheap/namecheap_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/ovh/ovh.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/ovh/ovh_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/pdns/README.md create mode 100644 vendor/github.com/xenolf/lego/providers/dns/pdns/pdns.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/pdns/pdns_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/rfc2136/rfc2136.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/rfc2136/rfc2136_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/route53/fixtures_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/route53/route53.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/route53/route53_integration_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/route53/route53_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/route53/testutil_test.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/vultr/vultr.go create mode 100644 vendor/github.com/xenolf/lego/providers/dns/vultr/vultr_test.go (limited to 'vendor/github.com/xenolf/lego/providers/dns') diff --git a/vendor/github.com/xenolf/lego/providers/dns/cloudflare/cloudflare.go b/vendor/github.com/xenolf/lego/providers/dns/cloudflare/cloudflare.go new file mode 100644 index 000000000..84952238d --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/cloudflare/cloudflare.go @@ -0,0 +1,223 @@ +// Package cloudflare implements a DNS provider for solving the DNS-01 +// challenge using cloudflare DNS. +package cloudflare + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/xenolf/lego/acme" +) + +// CloudFlareAPIURL represents the API endpoint to call. +// TODO: Unexport? +const CloudFlareAPIURL = "https://api.cloudflare.com/client/v4" + +// DNSProvider is an implementation of the acme.ChallengeProvider interface +type DNSProvider struct { + authEmail string + authKey string +} + +// NewDNSProvider returns a DNSProvider instance configured for cloudflare. +// Credentials must be passed in the environment variables: CLOUDFLARE_EMAIL +// and CLOUDFLARE_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + email := os.Getenv("CLOUDFLARE_EMAIL") + key := os.Getenv("CLOUDFLARE_API_KEY") + return NewDNSProviderCredentials(email, key) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for cloudflare. +func NewDNSProviderCredentials(email, key string) (*DNSProvider, error) { + if email == "" || key == "" { + return nil, fmt.Errorf("CloudFlare credentials missing") + } + + return &DNSProvider{ + authEmail: email, + authKey: key, + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS +// propagation. Adjusting here to cope with spikes in propagation times. +func (c *DNSProvider) Timeout() (timeout, interval time.Duration) { + return 120 * time.Second, 2 * time.Second +} + +// 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 := cloudFlareRecord{ + Type: "TXT", + Name: acme.UnFqdn(fqdn), + Content: value, + TTL: 120, + } + + body, err := json.Marshal(rec) + if err != nil { + return err + } + + _, err = c.makeRequest("POST", fmt.Sprintf("/zones/%s/dns_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) + + record, err := c.findTxtRecord(fqdn) + if err != nil { + return err + } + + _, err = c.makeRequest("DELETE", fmt.Sprintf("/zones/%s/dns_records/%s", record.ZoneID, record.ID), nil) + if err != nil { + return err + } + + return nil +} + +func (c *DNSProvider) getHostedZoneID(fqdn string) (string, error) { + // HostedZone represents a CloudFlare DNS zone + type HostedZone struct { + ID string `json:"id"` + Name string `json:"name"` + } + + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return "", err + } + + result, err := c.makeRequest("GET", "/zones?name="+acme.UnFqdn(authZone), nil) + if err != nil { + return "", err + } + + var hostedZone []HostedZone + err = json.Unmarshal(result, &hostedZone) + if err != nil { + return "", err + } + + if len(hostedZone) != 1 { + return "", fmt.Errorf("Zone %s not found in CloudFlare for domain %s", authZone, fqdn) + } + + return hostedZone[0].ID, nil +} + +func (c *DNSProvider) findTxtRecord(fqdn string) (*cloudFlareRecord, error) { + zoneID, err := c.getHostedZoneID(fqdn) + if err != nil { + return nil, err + } + + result, err := c.makeRequest( + "GET", + fmt.Sprintf("/zones/%s/dns_records?per_page=1000&type=TXT&name=%s", zoneID, acme.UnFqdn(fqdn)), + nil, + ) + if err != nil { + return nil, err + } + + var records []cloudFlareRecord + err = json.Unmarshal(result, &records) + if err != nil { + return nil, err + } + + for _, rec := range records { + if rec.Name == acme.UnFqdn(fqdn) { + return &rec, nil + } + } + + return nil, fmt.Errorf("No existing record found for %s", fqdn) +} + +func (c *DNSProvider) makeRequest(method, uri string, body io.Reader) (json.RawMessage, error) { + // APIError contains error details for failed requests + type APIError struct { + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` + ErrorChain []APIError `json:"error_chain,omitempty"` + } + + // APIResponse represents a response from CloudFlare API + type APIResponse struct { + Success bool `json:"success"` + Errors []*APIError `json:"errors"` + Result json.RawMessage `json:"result"` + } + + req, err := http.NewRequest(method, fmt.Sprintf("%s%s", CloudFlareAPIURL, uri), body) + if err != nil { + return nil, err + } + + req.Header.Set("X-Auth-Email", c.authEmail) + req.Header.Set("X-Auth-Key", c.authKey) + //req.Header.Set("User-Agent", userAgent()) + + client := http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("Error querying Cloudflare API -> %v", err) + } + + defer resp.Body.Close() + + var r APIResponse + err = json.NewDecoder(resp.Body).Decode(&r) + if err != nil { + return nil, err + } + + if !r.Success { + if len(r.Errors) > 0 { + errStr := "" + for _, apiErr := range r.Errors { + errStr += fmt.Sprintf("\t Error: %d: %s", apiErr.Code, apiErr.Message) + for _, chainErr := range apiErr.ErrorChain { + errStr += fmt.Sprintf("<- %d: %s", chainErr.Code, chainErr.Message) + } + } + return nil, fmt.Errorf("Cloudflare API Error \n%s", errStr) + } + return nil, fmt.Errorf("Cloudflare API error") + } + + return r.Result, nil +} + +// cloudFlareRecord represents a CloudFlare DNS record +type cloudFlareRecord struct { + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + ID string `json:"id,omitempty"` + TTL int `json:"ttl,omitempty"` + ZoneID string `json:"zone_id,omitempty"` +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/cloudflare/cloudflare_test.go b/vendor/github.com/xenolf/lego/providers/dns/cloudflare/cloudflare_test.go new file mode 100644 index 000000000..19b5a40b9 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/cloudflare/cloudflare_test.go @@ -0,0 +1,80 @@ +package cloudflare + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + cflareLiveTest bool + cflareEmail string + cflareAPIKey string + cflareDomain string +) + +func init() { + cflareEmail = os.Getenv("CLOUDFLARE_EMAIL") + cflareAPIKey = os.Getenv("CLOUDFLARE_API_KEY") + cflareDomain = os.Getenv("CLOUDFLARE_DOMAIN") + if len(cflareEmail) > 0 && len(cflareAPIKey) > 0 && len(cflareDomain) > 0 { + cflareLiveTest = true + } +} + +func restoreCloudFlareEnv() { + os.Setenv("CLOUDFLARE_EMAIL", cflareEmail) + os.Setenv("CLOUDFLARE_API_KEY", cflareAPIKey) +} + +func TestNewDNSProviderValid(t *testing.T) { + os.Setenv("CLOUDFLARE_EMAIL", "") + os.Setenv("CLOUDFLARE_API_KEY", "") + _, err := NewDNSProviderCredentials("123", "123") + assert.NoError(t, err) + restoreCloudFlareEnv() +} + +func TestNewDNSProviderValidEnv(t *testing.T) { + os.Setenv("CLOUDFLARE_EMAIL", "test@example.com") + os.Setenv("CLOUDFLARE_API_KEY", "123") + _, err := NewDNSProvider() + assert.NoError(t, err) + restoreCloudFlareEnv() +} + +func TestNewDNSProviderMissingCredErr(t *testing.T) { + os.Setenv("CLOUDFLARE_EMAIL", "") + os.Setenv("CLOUDFLARE_API_KEY", "") + _, err := NewDNSProvider() + assert.EqualError(t, err, "CloudFlare credentials missing") + restoreCloudFlareEnv() +} + +func TestCloudFlarePresent(t *testing.T) { + if !cflareLiveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProviderCredentials(cflareEmail, cflareAPIKey) + assert.NoError(t, err) + + err = provider.Present(cflareDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestCloudFlareCleanUp(t *testing.T) { + if !cflareLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 2) + + provider, err := NewDNSProviderCredentials(cflareEmail, cflareAPIKey) + assert.NoError(t, err) + + err = provider.CleanUp(cflareDomain, "", "123d==") + assert.NoError(t, err) +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/digitalocean/digitalocean.go b/vendor/github.com/xenolf/lego/providers/dns/digitalocean/digitalocean.go new file mode 100644 index 000000000..da261b39a --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/digitalocean/digitalocean.go @@ -0,0 +1,166 @@ +// Package digitalocean implements a DNS provider for solving the DNS-01 +// challenge using digitalocean DNS. +package digitalocean + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "sync" + "time" + + "github.com/xenolf/lego/acme" +) + +// DNSProvider is an implementation of the acme.ChallengeProvider interface +// that uses DigitalOcean's REST API to manage TXT records for a domain. +type DNSProvider struct { + apiAuthToken string + recordIDs map[string]int + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Digital +// Ocean. Credentials must be passed in the environment variable: +// DO_AUTH_TOKEN. +func NewDNSProvider() (*DNSProvider, error) { + apiAuthToken := os.Getenv("DO_AUTH_TOKEN") + return NewDNSProviderCredentials(apiAuthToken) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for Digital Ocean. +func NewDNSProviderCredentials(apiAuthToken string) (*DNSProvider, error) { + if apiAuthToken == "" { + return nil, fmt.Errorf("DigitalOcean credentials missing") + } + return &DNSProvider{ + apiAuthToken: apiAuthToken, + recordIDs: make(map[string]int), + }, nil +} + +// Present creates a TXT record using the specified parameters +func (d *DNSProvider) 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, _ := acme.DNS01Record(domain, keyAuth) + + authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) + if err != nil { + return fmt.Errorf("Could not determine zone for domain: '%s'. %s", domain, err) + } + + authZone = acme.UnFqdn(authZone) + + reqURL := fmt.Sprintf("%s/v2/domains/%s/records", digitalOceanBaseURL, authZone) + 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)) + + client := http.Client{Timeout: 30 * time.Second} + 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 *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := acme.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) + } + + authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) + if err != nil { + return fmt.Errorf("Could not determine zone for domain: '%s'. %s", domain, err) + } + + authZone = acme.UnFqdn(authZone) + + reqURL := fmt.Sprintf("%s/v2/domains/%s/records/%d", digitalOceanBaseURL, authZone, 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)) + + client := http.Client{Timeout: 30 * time.Second} + 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/vendor/github.com/xenolf/lego/providers/dns/digitalocean/digitalocean_test.go b/vendor/github.com/xenolf/lego/providers/dns/digitalocean/digitalocean_test.go new file mode 100644 index 000000000..7498508ba --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/digitalocean/digitalocean_test.go @@ -0,0 +1,117 @@ +package digitalocean + +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 := NewDNSProviderCredentials(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 := NewDNSProviderCredentials(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/vendor/github.com/xenolf/lego/providers/dns/dnsimple/dnsimple.go b/vendor/github.com/xenolf/lego/providers/dns/dnsimple/dnsimple.go new file mode 100644 index 000000000..c903a35ce --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/dnsimple/dnsimple.go @@ -0,0 +1,141 @@ +// Package dnsimple implements a DNS provider for solving the DNS-01 challenge +// using dnsimple DNS. +package dnsimple + +import ( + "fmt" + "os" + "strings" + + "github.com/weppos/dnsimple-go/dnsimple" + "github.com/xenolf/lego/acme" +) + +// DNSProvider is an implementation of the acme.ChallengeProvider interface. +type DNSProvider struct { + client *dnsimple.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for dnsimple. +// Credentials must be passed in the environment variables: DNSIMPLE_EMAIL +// and DNSIMPLE_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + email := os.Getenv("DNSIMPLE_EMAIL") + key := os.Getenv("DNSIMPLE_API_KEY") + return NewDNSProviderCredentials(email, key) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for dnsimple. +func NewDNSProviderCredentials(email, key string) (*DNSProvider, error) { + if email == "" || key == "" { + return nil, fmt.Errorf("DNSimple credentials missing") + } + + return &DNSProvider{ + client: dnsimple.NewClient(key, email), + }, nil +} + +// Present creates a TXT record to fulfil the dns-01 challenge. +func (c *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + + zoneID, zoneName, err := c.getHostedZone(domain) + if err != nil { + return err + } + + recordAttributes := c.newTxtRecord(zoneName, fqdn, value, ttl) + _, _, err = c.client.Domains.CreateRecord(zoneID, *recordAttributes) + if err != nil { + return fmt.Errorf("DNSimple API call failed: %v", 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) + + records, err := c.findTxtRecords(domain, fqdn) + if err != nil { + return err + } + + for _, rec := range records { + _, err := c.client.Domains.DeleteRecord(rec.DomainId, rec.Id) + if err != nil { + return err + } + } + return nil +} + +func (c *DNSProvider) getHostedZone(domain string) (string, string, error) { + zones, _, err := c.client.Domains.List() + if err != nil { + return "", "", fmt.Errorf("DNSimple API call failed: %v", err) + } + + authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) + if err != nil { + return "", "", err + } + + var hostedZone dnsimple.Domain + for _, zone := range zones { + if zone.Name == acme.UnFqdn(authZone) { + hostedZone = zone + } + } + + if hostedZone.Id == 0 { + return "", "", fmt.Errorf("Zone %s not found in DNSimple for domain %s", authZone, domain) + + } + + return fmt.Sprintf("%v", hostedZone.Id), hostedZone.Name, nil +} + +func (c *DNSProvider) findTxtRecords(domain, fqdn string) ([]dnsimple.Record, error) { + zoneID, zoneName, err := c.getHostedZone(domain) + if err != nil { + return nil, err + } + + var records []dnsimple.Record + result, _, err := c.client.Domains.ListRecords(zoneID, "", "TXT") + if err != nil { + return records, fmt.Errorf("DNSimple API call has failed: %v", err) + } + + recordName := c.extractRecordName(fqdn, zoneName) + for _, record := range result { + if record.Name == recordName { + records = append(records, record) + } + } + + return records, nil +} + +func (c *DNSProvider) newTxtRecord(zone, fqdn, value string, ttl int) *dnsimple.Record { + name := c.extractRecordName(fqdn, zone) + + return &dnsimple.Record{ + Type: "TXT", + Name: name, + Content: value, + TTL: ttl, + } +} + +func (c *DNSProvider) extractRecordName(fqdn, domain string) string { + name := acme.UnFqdn(fqdn) + if idx := strings.Index(name, "."+domain); idx != -1 { + return name[:idx] + } + return name +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/dnsimple/dnsimple_test.go b/vendor/github.com/xenolf/lego/providers/dns/dnsimple/dnsimple_test.go new file mode 100644 index 000000000..4926b3df9 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/dnsimple/dnsimple_test.go @@ -0,0 +1,79 @@ +package dnsimple + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + dnsimpleLiveTest bool + dnsimpleEmail string + dnsimpleAPIKey string + dnsimpleDomain string +) + +func init() { + dnsimpleEmail = os.Getenv("DNSIMPLE_EMAIL") + dnsimpleAPIKey = os.Getenv("DNSIMPLE_API_KEY") + dnsimpleDomain = os.Getenv("DNSIMPLE_DOMAIN") + if len(dnsimpleEmail) > 0 && len(dnsimpleAPIKey) > 0 && len(dnsimpleDomain) > 0 { + dnsimpleLiveTest = true + } +} + +func restoreDNSimpleEnv() { + os.Setenv("DNSIMPLE_EMAIL", dnsimpleEmail) + os.Setenv("DNSIMPLE_API_KEY", dnsimpleAPIKey) +} + +func TestNewDNSProviderValid(t *testing.T) { + os.Setenv("DNSIMPLE_EMAIL", "") + os.Setenv("DNSIMPLE_API_KEY", "") + _, err := NewDNSProviderCredentials("example@example.com", "123") + assert.NoError(t, err) + restoreDNSimpleEnv() +} +func TestNewDNSProviderValidEnv(t *testing.T) { + os.Setenv("DNSIMPLE_EMAIL", "example@example.com") + os.Setenv("DNSIMPLE_API_KEY", "123") + _, err := NewDNSProvider() + assert.NoError(t, err) + restoreDNSimpleEnv() +} + +func TestNewDNSProviderMissingCredErr(t *testing.T) { + os.Setenv("DNSIMPLE_EMAIL", "") + os.Setenv("DNSIMPLE_API_KEY", "") + _, err := NewDNSProvider() + assert.EqualError(t, err, "DNSimple credentials missing") + restoreDNSimpleEnv() +} + +func TestLiveDNSimplePresent(t *testing.T) { + if !dnsimpleLiveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProviderCredentials(dnsimpleEmail, dnsimpleAPIKey) + assert.NoError(t, err) + + err = provider.Present(dnsimpleDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestLiveDNSimpleCleanUp(t *testing.T) { + if !dnsimpleLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 1) + + provider, err := NewDNSProviderCredentials(dnsimpleEmail, dnsimpleAPIKey) + assert.NoError(t, err) + + err = provider.CleanUp(dnsimpleDomain, "", "123d==") + assert.NoError(t, err) +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/dnsmadeeasy/dnsmadeeasy.go b/vendor/github.com/xenolf/lego/providers/dns/dnsmadeeasy/dnsmadeeasy.go new file mode 100644 index 000000000..c4363a4eb --- /dev/null +++ b/vendor/github.com/xenolf/lego/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/vendor/github.com/xenolf/lego/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go b/vendor/github.com/xenolf/lego/providers/dns/dnsmadeeasy/dnsmadeeasy_test.go new file mode 100644 index 000000000..e860ecb69 --- /dev/null +++ b/vendor/github.com/xenolf/lego/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) +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/dyn/dyn.go b/vendor/github.com/xenolf/lego/providers/dns/dyn/dyn.go new file mode 100644 index 000000000..384bc850c --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/dyn/dyn.go @@ -0,0 +1,274 @@ +// 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" + "os" + "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 DNSProvider instance configured for Dyn DNS. +// Credentials must be passed in the environment variables: DYN_CUSTOMER_NAME, +// DYN_USER_NAME and DYN_PASSWORD. +func NewDNSProvider() (*DNSProvider, error) { + customerName := os.Getenv("DYN_CUSTOMER_NAME") + userName := os.Getenv("DYN_USER_NAME") + password := os.Getenv("DYN_PASSWORD") + return NewDNSProviderCredentials(customerName, userName, password) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for Dyn DNS. +func NewDNSProviderCredentials(customerName, userName, password string) (*DNSProvider, error) { + if customerName == "" || userName == "" || password == "" { + return nil, fmt.Errorf("DynDNS credentials missing") + } + + 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 { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return err + } + + err = d.login() + if err != nil { + return err + } + + data := map[string]interface{}{ + "rdata": map[string]string{ + "txtdata": value, + }, + "ttl": strconv.Itoa(ttl), + } + + resource := fmt.Sprintf("TXTRecord/%s/%s/", authZone, fqdn) + _, err = d.sendRequest("POST", resource, data) + if err != nil { + return err + } + + err = d.publish(authZone, "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(zone, 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/", zone) + _, 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 { + fqdn, _, _ := acme.DNS01Record(domain, keyAuth) + + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return err + } + + err = d.login() + if err != nil { + return err + } + + resource := fmt.Sprintf("TXTRecord/%s/%s/", authZone, 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(authZone, "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/vendor/github.com/xenolf/lego/providers/dns/dyn/dyn_test.go b/vendor/github.com/xenolf/lego/providers/dns/dyn/dyn_test.go new file mode 100644 index 000000000..0d28d5d0e --- /dev/null +++ b/vendor/github.com/xenolf/lego/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() + 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() + assert.NoError(t, err) + + err = provider.CleanUp(dynDomain, "", "123d==") + assert.NoError(t, err) +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/gandi/gandi.go b/vendor/github.com/xenolf/lego/providers/dns/gandi/gandi.go new file mode 100644 index 000000000..422b02a21 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/gandi/gandi.go @@ -0,0 +1,472 @@ +// Package gandi implements a DNS provider for solving the DNS-01 +// challenge using Gandi DNS. +package gandi + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/xenolf/lego/acme" +) + +// Gandi API reference: http://doc.rpc.gandi.net/index.html +// Gandi API domain examples: http://doc.rpc.gandi.net/domain/faq.html + +var ( + // endpoint is the Gandi XML-RPC endpoint used by Present and + // CleanUp. It is overridden during tests. + endpoint = "https://rpc.gandi.net/xmlrpc/" + // findZoneByFqdn determines the DNS zone of an fqdn. It is overridden + // during tests. + findZoneByFqdn = acme.FindZoneByFqdn +) + +// inProgressInfo contains information about an in-progress challenge +type inProgressInfo struct { + zoneID int // zoneID of gandi zone to restore in CleanUp + newZoneID int // zoneID of temporary gandi zone containing TXT record + authZone string // the domain name registered at gandi with trailing "." +} + +// DNSProvider is an implementation of the +// acme.ChallengeProviderTimeout interface that uses Gandi's XML-RPC +// API to manage TXT records for a domain. +type DNSProvider struct { + apiKey string + inProgressFQDNs map[string]inProgressInfo + inProgressAuthZones map[string]struct{} + inProgressMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Gandi. +// Credentials must be passed in the environment variable: GANDI_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + apiKey := os.Getenv("GANDI_API_KEY") + return NewDNSProviderCredentials(apiKey) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for Gandi. +func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { + if apiKey == "" { + return nil, fmt.Errorf("No Gandi API Key given") + } + return &DNSProvider{ + apiKey: apiKey, + inProgressFQDNs: make(map[string]inProgressInfo), + inProgressAuthZones: make(map[string]struct{}), + }, nil +} + +// Present creates a TXT record using the specified parameters. It +// does this by creating and activating a new temporary Gandi DNS +// zone. This new zone contains the TXT record. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + if ttl < 300 { + ttl = 300 // 300 is gandi minimum value for ttl + } + // find authZone and Gandi zone_id for fqdn + authZone, err := findZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return fmt.Errorf("Gandi DNS: findZoneByFqdn failure: %v", err) + } + zoneID, err := d.getZoneID(authZone) + if err != nil { + return err + } + // determine name of TXT record + if !strings.HasSuffix( + strings.ToLower(fqdn), strings.ToLower("."+authZone)) { + return fmt.Errorf( + "Gandi DNS: unexpected authZone %s for fqdn %s", authZone, fqdn) + } + name := fqdn[:len(fqdn)-len("."+authZone)] + // acquire lock and check there is not a challenge already in + // progress for this value of authZone + d.inProgressMu.Lock() + defer d.inProgressMu.Unlock() + if _, ok := d.inProgressAuthZones[authZone]; ok { + return fmt.Errorf( + "Gandi DNS: challenge already in progress for authZone %s", + authZone) + } + // perform API actions to create and activate new gandi zone + // containing the required TXT record + newZoneName := fmt.Sprintf( + "%s [ACME Challenge %s]", + acme.UnFqdn(authZone), time.Now().Format(time.RFC822Z)) + newZoneID, err := d.cloneZone(zoneID, newZoneName) + if err != nil { + return err + } + newZoneVersion, err := d.newZoneVersion(newZoneID) + if err != nil { + return err + } + err = d.addTXTRecord(newZoneID, newZoneVersion, name, value, ttl) + if err != nil { + return err + } + err = d.setZoneVersion(newZoneID, newZoneVersion) + if err != nil { + return err + } + err = d.setZone(authZone, newZoneID) + if err != nil { + return err + } + // save data necessary for CleanUp + d.inProgressFQDNs[fqdn] = inProgressInfo{ + zoneID: zoneID, + newZoneID: newZoneID, + authZone: authZone, + } + d.inProgressAuthZones[authZone] = struct{}{} + return nil +} + +// CleanUp removes the TXT record matching the specified +// parameters. It does this by restoring the old Gandi DNS zone and +// removing the temporary one created by Present. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := acme.DNS01Record(domain, keyAuth) + // acquire lock and retrieve zoneID, newZoneID and authZone + d.inProgressMu.Lock() + defer d.inProgressMu.Unlock() + if _, ok := d.inProgressFQDNs[fqdn]; !ok { + // if there is no cleanup information then just return + return nil + } + zoneID := d.inProgressFQDNs[fqdn].zoneID + newZoneID := d.inProgressFQDNs[fqdn].newZoneID + authZone := d.inProgressFQDNs[fqdn].authZone + delete(d.inProgressFQDNs, fqdn) + delete(d.inProgressAuthZones, authZone) + // perform API actions to restore old gandi zone for authZone + err := d.setZone(authZone, zoneID) + if err != nil { + return err + } + err = d.deleteZone(newZoneID) + if err != nil { + return err + } + return nil +} + +// Timeout returns the values (40*time.Minute, 60*time.Second) which +// are used by the acme package as timeout and check interval values +// when checking for DNS record propagation with Gandi. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return 40 * time.Minute, 60 * time.Second +} + +// types for XML-RPC method calls and parameters + +type param interface { + param() +} +type paramString struct { + XMLName xml.Name `xml:"param"` + Value string `xml:"value>string"` +} +type paramInt struct { + XMLName xml.Name `xml:"param"` + Value int `xml:"value>int"` +} + +type structMember interface { + structMember() +} +type structMemberString struct { + Name string `xml:"name"` + Value string `xml:"value>string"` +} +type structMemberInt struct { + Name string `xml:"name"` + Value int `xml:"value>int"` +} +type paramStruct struct { + XMLName xml.Name `xml:"param"` + StructMembers []structMember `xml:"value>struct>member"` +} + +func (p paramString) param() {} +func (p paramInt) param() {} +func (m structMemberString) structMember() {} +func (m structMemberInt) structMember() {} +func (p paramStruct) param() {} + +type methodCall struct { + XMLName xml.Name `xml:"methodCall"` + MethodName string `xml:"methodName"` + Params []param `xml:"params"` +} + +// types for XML-RPC responses + +type response interface { + faultCode() int + faultString() string +} + +type responseFault struct { + FaultCode int `xml:"fault>value>struct>member>value>int"` + FaultString string `xml:"fault>value>struct>member>value>string"` +} + +func (r responseFault) faultCode() int { return r.FaultCode } +func (r responseFault) faultString() string { return r.FaultString } + +type responseStruct struct { + responseFault + StructMembers []struct { + Name string `xml:"name"` + ValueInt int `xml:"value>int"` + } `xml:"params>param>value>struct>member"` +} + +type responseInt struct { + responseFault + Value int `xml:"params>param>value>int"` +} + +type responseBool struct { + responseFault + Value bool `xml:"params>param>value>boolean"` +} + +// POSTing/Marshalling/Unmarshalling + +type rpcError struct { + faultCode int + faultString string +} + +func (e rpcError) Error() string { + return fmt.Sprintf( + "Gandi DNS: RPC Error: (%d) %s", e.faultCode, e.faultString) +} + +func httpPost(url string, bodyType string, body io.Reader) ([]byte, error) { + client := http.Client{Timeout: 60 * time.Second} + resp, err := client.Post(url, bodyType, body) + if err != nil { + return nil, fmt.Errorf("Gandi DNS: HTTP Post Error: %v", err) + } + defer resp.Body.Close() + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("Gandi DNS: HTTP Post Error: %v", err) + } + return b, nil +} + +// rpcCall makes an XML-RPC call to Gandi's RPC endpoint by +// marshalling the data given in the call argument to XML and sending +// that via HTTP Post to Gandi. The response is then unmarshalled into +// the resp argument. +func rpcCall(call *methodCall, resp response) error { + // marshal + b, err := xml.MarshalIndent(call, "", " ") + if err != nil { + return fmt.Errorf("Gandi DNS: Marshal Error: %v", err) + } + // post + b = append([]byte(``+"\n"), b...) + respBody, err := httpPost(endpoint, "text/xml", bytes.NewReader(b)) + if err != nil { + return err + } + // unmarshal + err = xml.Unmarshal(respBody, resp) + if err != nil { + return fmt.Errorf("Gandi DNS: Unmarshal Error: %v", err) + } + if resp.faultCode() != 0 { + return rpcError{ + faultCode: resp.faultCode(), faultString: resp.faultString()} + } + return nil +} + +// functions to perform API actions + +func (d *DNSProvider) getZoneID(domain string) (int, error) { + resp := &responseStruct{} + err := rpcCall(&methodCall{ + MethodName: "domain.info", + Params: []param{ + paramString{Value: d.apiKey}, + paramString{Value: domain}, + }, + }, resp) + if err != nil { + return 0, err + } + var zoneID int + for _, member := range resp.StructMembers { + if member.Name == "zone_id" { + zoneID = member.ValueInt + } + } + if zoneID == 0 { + return 0, fmt.Errorf( + "Gandi DNS: Could not determine zone_id for %s", domain) + } + return zoneID, nil +} + +func (d *DNSProvider) cloneZone(zoneID int, name string) (int, error) { + resp := &responseStruct{} + err := rpcCall(&methodCall{ + MethodName: "domain.zone.clone", + Params: []param{ + paramString{Value: d.apiKey}, + paramInt{Value: zoneID}, + paramInt{Value: 0}, + paramStruct{ + StructMembers: []structMember{ + structMemberString{ + Name: "name", + Value: name, + }}, + }, + }, + }, resp) + if err != nil { + return 0, err + } + var newZoneID int + for _, member := range resp.StructMembers { + if member.Name == "id" { + newZoneID = member.ValueInt + } + } + if newZoneID == 0 { + return 0, fmt.Errorf("Gandi DNS: Could not determine cloned zone_id") + } + return newZoneID, nil +} + +func (d *DNSProvider) newZoneVersion(zoneID int) (int, error) { + resp := &responseInt{} + err := rpcCall(&methodCall{ + MethodName: "domain.zone.version.new", + Params: []param{ + paramString{Value: d.apiKey}, + paramInt{Value: zoneID}, + }, + }, resp) + if err != nil { + return 0, err + } + if resp.Value == 0 { + return 0, fmt.Errorf("Gandi DNS: Could not create new zone version") + } + return resp.Value, nil +} + +func (d *DNSProvider) addTXTRecord(zoneID int, version int, name string, value string, ttl int) error { + resp := &responseStruct{} + err := rpcCall(&methodCall{ + MethodName: "domain.zone.record.add", + Params: []param{ + paramString{Value: d.apiKey}, + paramInt{Value: zoneID}, + paramInt{Value: version}, + paramStruct{ + StructMembers: []structMember{ + structMemberString{ + Name: "type", + Value: "TXT", + }, structMemberString{ + Name: "name", + Value: name, + }, structMemberString{ + Name: "value", + Value: value, + }, structMemberInt{ + Name: "ttl", + Value: ttl, + }}, + }, + }, + }, resp) + if err != nil { + return err + } + return nil +} + +func (d *DNSProvider) setZoneVersion(zoneID int, version int) error { + resp := &responseBool{} + err := rpcCall(&methodCall{ + MethodName: "domain.zone.version.set", + Params: []param{ + paramString{Value: d.apiKey}, + paramInt{Value: zoneID}, + paramInt{Value: version}, + }, + }, resp) + if err != nil { + return err + } + if !resp.Value { + return fmt.Errorf("Gandi DNS: could not set zone version") + } + return nil +} + +func (d *DNSProvider) setZone(domain string, zoneID int) error { + resp := &responseStruct{} + err := rpcCall(&methodCall{ + MethodName: "domain.zone.set", + Params: []param{ + paramString{Value: d.apiKey}, + paramString{Value: domain}, + paramInt{Value: zoneID}, + }, + }, resp) + if err != nil { + return err + } + var respZoneID int + for _, member := range resp.StructMembers { + if member.Name == "zone_id" { + respZoneID = member.ValueInt + } + } + if respZoneID != zoneID { + return fmt.Errorf( + "Gandi DNS: Could not set new zone_id for %s", domain) + } + return nil +} + +func (d *DNSProvider) deleteZone(zoneID int) error { + resp := &responseBool{} + err := rpcCall(&methodCall{ + MethodName: "domain.zone.delete", + Params: []param{ + paramString{Value: d.apiKey}, + paramInt{Value: zoneID}, + }, + }, resp) + if err != nil { + return err + } + if !resp.Value { + return fmt.Errorf("Gandi DNS: could not delete zone_id") + } + return nil +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/gandi/gandi_test.go b/vendor/github.com/xenolf/lego/providers/dns/gandi/gandi_test.go new file mode 100644 index 000000000..15919e2eb --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/gandi/gandi_test.go @@ -0,0 +1,939 @@ +package gandi + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "regexp" + "strings" + "testing" + + "github.com/xenolf/lego/acme" +) + +// stagingServer is the Let's Encrypt staging server used by the live test +const stagingServer = "https://acme-staging.api.letsencrypt.org/directory" + +// user implements acme.User and is used by the live test +type user struct { + Email string + Registration *acme.RegistrationResource + key crypto.PrivateKey +} + +func (u *user) GetEmail() string { + return u.Email +} +func (u *user) GetRegistration() *acme.RegistrationResource { + return u.Registration +} +func (u *user) GetPrivateKey() crypto.PrivateKey { + return u.key +} + +// TestDNSProvider runs Present and CleanUp against a fake Gandi RPC +// Server, whose responses are predetermined for particular requests. +func TestDNSProvider(t *testing.T) { + fakeAPIKey := "123412341234123412341234" + fakeKeyAuth := "XXXX" + provider, err := NewDNSProviderCredentials(fakeAPIKey) + if err != nil { + t.Fatal(err) + } + regexpDate, err := regexp.Compile(`\[ACME Challenge [^\]:]*:[^\]]*\]`) + if err != nil { + t.Fatal(err) + } + // start fake RPC server + fakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "text/xml" { + t.Fatalf("Content-Type: text/xml header not found") + } + req, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + req = regexpDate.ReplaceAllLiteral( + req, []byte(`[ACME Challenge 01 Jan 16 00:00 +0000]`)) + resp, ok := serverResponses[string(req)] + if !ok { + t.Fatalf("Server response for request not found") + } + _, err = io.Copy(w, strings.NewReader(resp)) + if err != nil { + t.Fatal(err) + } + })) + defer fakeServer.Close() + // define function to override findZoneByFqdn with + fakeFindZoneByFqdn := func(fqdn string, nameserver []string) (string, error) { + return "example.com.", nil + } + // override gandi endpoint and findZoneByFqdn function + savedEndpoint, savedFindZoneByFqdn := endpoint, findZoneByFqdn + defer func() { + endpoint, findZoneByFqdn = savedEndpoint, savedFindZoneByFqdn + }() + endpoint, findZoneByFqdn = fakeServer.URL+"/", fakeFindZoneByFqdn + // run Present + err = provider.Present("abc.def.example.com", "", fakeKeyAuth) + if err != nil { + t.Fatal(err) + } + // run CleanUp + err = provider.CleanUp("abc.def.example.com", "", fakeKeyAuth) + if err != nil { + t.Fatal(err) + } +} + +// TestDNSProviderLive performs a live test to obtain a certificate +// using the Let's Encrypt staging server. It runs provided that both +// the environment variables GANDI_API_KEY and GANDI_TEST_DOMAIN are +// set. Otherwise the test is skipped. +// +// To complete this test, go test must be run with the -timeout=40m +// flag, since the default timeout of 10m is insufficient. +func TestDNSProviderLive(t *testing.T) { + apiKey := os.Getenv("GANDI_API_KEY") + domain := os.Getenv("GANDI_TEST_DOMAIN") + if apiKey == "" || domain == "" { + t.Skip("skipping live test") + } + // create a user. + const rsaKeySize = 2048 + privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize) + if err != nil { + t.Fatal(err) + } + myUser := user{ + Email: "test@example.com", + key: privateKey, + } + // create a client using staging server + client, err := acme.NewClient(stagingServer, &myUser, acme.RSA2048) + if err != nil { + t.Fatal(err) + } + provider, err := NewDNSProviderCredentials(apiKey) + if err != nil { + t.Fatal(err) + } + err = client.SetChallengeProvider(acme.DNS01, provider) + if err != nil { + t.Fatal(err) + } + client.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.TLSSNI01}) + // register and agree tos + reg, err := client.Register() + if err != nil { + t.Fatal(err) + } + myUser.Registration = reg + err = client.AgreeToTOS() + if err != nil { + t.Fatal(err) + } + // complete the challenge + bundle := false + _, failures := client.ObtainCertificate([]string{domain}, bundle, nil) + if len(failures) > 0 { + t.Fatal(failures) + } +} + +// serverResponses is the XML-RPC Request->Response map used by the +// fake RPC server. It was generated by recording a real RPC session +// which resulted in the successful issue of a cert, and then +// anonymizing the RPC data. +var serverResponses = map[string]string{ + // Present Request->Response 1 (getZoneID) + ` + + domain.info + + + 123412341234123412341234 + + + + + example.com. + + +`: ` + + + + + +date_updated +20160216T16:14:23 + + +date_delete +20170331T16:04:06 + + +is_premium +0 + + +date_hold_begin +20170215T02:04:06 + + +date_registry_end +20170215T02:04:06 + + +authinfo_expiration_date +20161211T21:31:20 + + +contacts + + +owner + + +handle +LEGO-GANDI + + +id +111111 + + + + +admin + + +handle +LEGO-GANDI + + +id +111111 + + + + +bill + + +handle +LEGO-GANDI + + +id +111111 + + + + +tech + + +handle +LEGO-GANDI + + +id +111111 + + + + +reseller + + + + +nameservers + +a.dns.gandi.net +b.dns.gandi.net +c.dns.gandi.net + + + +date_restore_end +20170501T02:04:06 + + +id +2222222 + + +authinfo +ABCDABCDAB + + +status + +clientTransferProhibited +serverTransferProhibited + + + +tags + + + + +date_hold_end +20170401T02:04:06 + + +services + +gandidns +gandimail + + + +date_pending_delete_end +20170506T02:04:06 + + +zone_id +1234567 + + +date_renew_begin +20120101T00:00:00 + + +fqdn +example.com + + +autorenew + + +date_registry_creation +20150215T02:04:06 + + +tld +org + + +date_created +20150215T03:04:06 + + + + + +`, + // Present Request->Response 2 (cloneZone) + ` + + domain.zone.clone + + + 123412341234123412341234 + + + + + 1234567 + + + + + 0 + + + + + + + name + + example.com [ACME Challenge 01 Jan 16 00:00 +0000] + + + + + +`: ` + + + + + +name +example.com [ACME Challenge 01 Jan 16 00:00 +0000] + + +versions + +1 + + + +date_updated +20160216T16:24:29 + + +id +7654321 + + +owner +LEGO-GANDI + + +version +1 + + +domains +0 + + +public +0 + + + + + +`, + // Present Request->Response 3 (newZoneVersion) + ` + + domain.zone.version.new + + + 123412341234123412341234 + + + + + 7654321 + + +`: ` + + + +2 + + + +`, + // Present Request->Response 4 (addTXTRecord) + ` + + domain.zone.record.add + + + 123412341234123412341234 + + + + + 7654321 + + + + + 2 + + + + + + + type + + TXT + + + + name + + _acme-challenge.abc.def + + + + value + + ezRpBPY8wH8djMLYjX2uCKPwiKDkFZ1SFMJ6ZXGlHrQ + + + + ttl + + 300 + + + + + +`: ` + + + + + +name +_acme-challenge.abc.def + + +type +TXT + + +id +3333333333 + + +value +"ezRpBPY8wH8djMLYjX2uCKPwiKDkFZ1SFMJ6ZXGlHrQ" + + +ttl +300 + + + + + +`, + // Present Request->Response 5 (setZoneVersion) + ` + + domain.zone.version.set + + + 123412341234123412341234 + + + + + 7654321 + + + + + 2 + + +`: ` + + + +1 + + + +`, + // Present Request->Response 6 (setZone) + ` + + domain.zone.set + + + 123412341234123412341234 + + + + + example.com. + + + + + 7654321 + + +`: ` + + + + + +date_updated +20160216T16:14:23 + + +date_delete +20170331T16:04:06 + + +is_premium +0 + + +date_hold_begin +20170215T02:04:06 + + +date_registry_end +20170215T02:04:06 + + +authinfo_expiration_date +20161211T21:31:20 + + +contacts + + +owner + + +handle +LEGO-GANDI + + +id +111111 + + + + +admin + + +handle +LEGO-GANDI + + +id +111111 + + + + +bill + + +handle +LEGO-GANDI + + +id +111111 + + + + +tech + + +handle +LEGO-GANDI + + +id +111111 + + + + +reseller + + + + +nameservers + +a.dns.gandi.net +b.dns.gandi.net +c.dns.gandi.net + + + +date_restore_end +20170501T02:04:06 + + +id +2222222 + + +authinfo +ABCDABCDAB + + +status + +clientTransferProhibited +serverTransferProhibited + + + +tags + + + + +date_hold_end +20170401T02:04:06 + + +services + +gandidns +gandimail + + + +date_pending_delete_end +20170506T02:04:06 + + +zone_id +7654321 + + +date_renew_begin +20120101T00:00:00 + + +fqdn +example.com + + +autorenew + + +date_registry_creation +20150215T02:04:06 + + +tld +org + + +date_created +20150215T03:04:06 + + + + + +`, + // CleanUp Request->Response 1 (setZone) + ` + + domain.zone.set + + + 123412341234123412341234 + + + + + example.com. + + + + + 1234567 + + +`: ` + + + + + +date_updated +20160216T16:24:38 + + +date_delete +20170331T16:04:06 + + +is_premium +0 + + +date_hold_begin +20170215T02:04:06 + + +date_registry_end +20170215T02:04:06 + + +authinfo_expiration_date +20161211T21:31:20 + + +contacts + + +owner + + +handle +LEGO-GANDI + + +id +111111 + + + + +admin + + +handle +LEGO-GANDI + + +id +111111 + + + + +bill + + +handle +LEGO-GANDI + + +id +111111 + + + + +tech + + +handle +LEGO-GANDI + + +id +111111 + + + + +reseller + + + + +nameservers + +a.dns.gandi.net +b.dns.gandi.net +c.dns.gandi.net + + + +date_restore_end +20170501T02:04:06 + + +id +2222222 + + +authinfo +ABCDABCDAB + + +status + +clientTransferProhibited +serverTransferProhibited + + + +tags + + + + +date_hold_end +20170401T02:04:06 + + +services + +gandidns +gandimail + + + +date_pending_delete_end +20170506T02:04:06 + + +zone_id +1234567 + + +date_renew_begin +20120101T00:00:00 + + +fqdn +example.com + + +autorenew + + +date_registry_creation +20150215T02:04:06 + + +tld +org + + +date_created +20150215T03:04:06 + + + + + +`, + // CleanUp Request->Response 2 (deleteZone) + ` + + domain.zone.delete + + + 123412341234123412341234 + + + + + 7654321 + + +`: ` + + + +1 + + + +`, +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/googlecloud/googlecloud.go b/vendor/github.com/xenolf/lego/providers/dns/googlecloud/googlecloud.go new file mode 100644 index 000000000..b8d9951c9 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/googlecloud/googlecloud.go @@ -0,0 +1,158 @@ +// Package googlecloud implements a DNS provider for solving the DNS-01 +// challenge using Google Cloud DNS. +package googlecloud + +import ( + "fmt" + "os" + "time" + + "github.com/xenolf/lego/acme" + + "golang.org/x/net/context" + "golang.org/x/oauth2/google" + + "google.golang.org/api/dns/v1" +) + +// DNSProvider is an implementation of the DNSProvider interface. +type DNSProvider struct { + project string + client *dns.Service +} + +// NewDNSProvider returns a DNSProvider instance configured for Google Cloud +// DNS. Credentials must be passed in the environment variable: GCE_PROJECT. +func NewDNSProvider() (*DNSProvider, error) { + project := os.Getenv("GCE_PROJECT") + return NewDNSProviderCredentials(project) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for Google Cloud DNS. +func NewDNSProviderCredentials(project string) (*DNSProvider, error) { + if project == "" { + return nil, fmt.Errorf("Google Cloud project name missing") + } + + client, err := google.DefaultClient(context.Background(), dns.NdevClouddnsReadwriteScope) + if err != nil { + return nil, fmt.Errorf("Unable to get Google Cloud client: %v", err) + } + svc, err := dns.New(client) + if err != nil { + return nil, fmt.Errorf("Unable to create Google Cloud DNS service: %v", err) + } + return &DNSProvider{ + project: project, + client: svc, + }, nil +} + +// Present creates a TXT record to fulfil the dns-01 challenge. +func (c *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + + zone, err := c.getHostedZone(domain) + if err != nil { + return err + } + + rec := &dns.ResourceRecordSet{ + Name: fqdn, + Rrdatas: []string{value}, + Ttl: int64(ttl), + Type: "TXT", + } + change := &dns.Change{ + Additions: []*dns.ResourceRecordSet{rec}, + } + + chg, err := c.client.Changes.Create(c.project, zone, change).Do() + if err != nil { + return err + } + + // wait for change to be acknowledged + for chg.Status == "pending" { + time.Sleep(time.Second) + + chg, err = c.client.Changes.Get(c.project, zone, chg.Id).Do() + 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) + + zone, err := c.getHostedZone(domain) + if err != nil { + return err + } + + records, err := c.findTxtRecords(zone, fqdn) + if err != nil { + return err + } + + for _, rec := range records { + change := &dns.Change{ + Deletions: []*dns.ResourceRecordSet{rec}, + } + _, err = c.client.Changes.Create(c.project, zone, change).Do() + if err != nil { + return err + } + } + return nil +} + +// Timeout customizes the timeout values used by the ACME package for checking +// DNS record validity. +func (c *DNSProvider) Timeout() (timeout, interval time.Duration) { + return 180 * time.Second, 5 * time.Second +} + +// getHostedZone returns the managed-zone +func (c *DNSProvider) getHostedZone(domain string) (string, error) { + authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) + if err != nil { + return "", err + } + + zones, err := c.client.ManagedZones. + List(c.project). + DnsName(authZone). + Do() + if err != nil { + return "", fmt.Errorf("GoogleCloud API call failed: %v", err) + } + + if len(zones.ManagedZones) == 0 { + return "", fmt.Errorf("No matching GoogleCloud domain found for domain %s", authZone) + } + + return zones.ManagedZones[0].Name, nil +} + +func (c *DNSProvider) findTxtRecords(zone, fqdn string) ([]*dns.ResourceRecordSet, error) { + + recs, err := c.client.ResourceRecordSets.List(c.project, zone).Do() + if err != nil { + return nil, err + } + + found := []*dns.ResourceRecordSet{} + for _, r := range recs.Rrsets { + if r.Type == "TXT" && r.Name == fqdn { + found = append(found, r) + } + } + + return found, nil +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/googlecloud/googlecloud_test.go b/vendor/github.com/xenolf/lego/providers/dns/googlecloud/googlecloud_test.go new file mode 100644 index 000000000..d73788163 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/googlecloud/googlecloud_test.go @@ -0,0 +1,85 @@ +package googlecloud + +import ( + "os" + "testing" + "time" + + "golang.org/x/net/context" + "golang.org/x/oauth2/google" + "google.golang.org/api/dns/v1" + + "github.com/stretchr/testify/assert" +) + +var ( + gcloudLiveTest bool + gcloudProject string + gcloudDomain string +) + +func init() { + gcloudProject = os.Getenv("GCE_PROJECT") + gcloudDomain = os.Getenv("GCE_DOMAIN") + _, err := google.DefaultClient(context.Background(), dns.NdevClouddnsReadwriteScope) + if err == nil && len(gcloudProject) > 0 && len(gcloudDomain) > 0 { + gcloudLiveTest = true + } +} + +func restoreGCloudEnv() { + os.Setenv("GCE_PROJECT", gcloudProject) +} + +func TestNewDNSProviderValid(t *testing.T) { + if !gcloudLiveTest { + t.Skip("skipping live test (requires credentials)") + } + os.Setenv("GCE_PROJECT", "") + _, err := NewDNSProviderCredentials("my-project") + assert.NoError(t, err) + restoreGCloudEnv() +} + +func TestNewDNSProviderValidEnv(t *testing.T) { + if !gcloudLiveTest { + t.Skip("skipping live test (requires credentials)") + } + os.Setenv("GCE_PROJECT", "my-project") + _, err := NewDNSProvider() + assert.NoError(t, err) + restoreGCloudEnv() +} + +func TestNewDNSProviderMissingCredErr(t *testing.T) { + os.Setenv("GCE_PROJECT", "") + _, err := NewDNSProvider() + assert.EqualError(t, err, "Google Cloud project name missing") + restoreGCloudEnv() +} + +func TestLiveGoogleCloudPresent(t *testing.T) { + if !gcloudLiveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProviderCredentials(gcloudProject) + assert.NoError(t, err) + + err = provider.Present(gcloudDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestLiveGoogleCloudCleanUp(t *testing.T) { + if !gcloudLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 1) + + provider, err := NewDNSProviderCredentials(gcloudProject) + assert.NoError(t, err) + + err = provider.CleanUp(gcloudDomain, "", "123d==") + assert.NoError(t, err) +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/linode/linode.go b/vendor/github.com/xenolf/lego/providers/dns/linode/linode.go new file mode 100644 index 000000000..a91d2b489 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/linode/linode.go @@ -0,0 +1,131 @@ +// Package linode implements a DNS provider for solving the DNS-01 challenge +// using Linode DNS. +package linode + +import ( + "errors" + "os" + "strings" + "time" + + "github.com/timewasted/linode/dns" + "github.com/xenolf/lego/acme" +) + +const ( + dnsMinTTLSecs = 300 + dnsUpdateFreqMins = 15 + dnsUpdateFudgeSecs = 120 +) + +type hostedZoneInfo struct { + domainId int + resourceName string +} + +// DNSProvider implements the acme.ChallengeProvider interface. +type DNSProvider struct { + linode *dns.DNS +} + +// NewDNSProvider returns a DNSProvider instance configured for Linode. +// Credentials must be passed in the environment variable: LINODE_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + apiKey := os.Getenv("LINODE_API_KEY") + return NewDNSProviderCredentials(apiKey) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for Linode. +func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { + if len(apiKey) == 0 { + return nil, errors.New("Linode credentials missing") + } + + return &DNSProvider{ + linode: dns.New(apiKey), + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS +// propagation. Adjusting here to cope with spikes in propagation times. +func (p *DNSProvider) Timeout() (timeout, interval time.Duration) { + // Since Linode only updates their zone files every X minutes, we need + // to figure out how many minutes we have to wait until we hit the next + // interval of X. We then wait another couple of minutes, just to be + // safe. Hopefully at some point during all of this, the record will + // have propagated throughout Linode's network. + minsRemaining := dnsUpdateFreqMins - (time.Now().Minute() % dnsUpdateFreqMins) + + timeout = (time.Duration(minsRemaining) * time.Minute) + + (dnsMinTTLSecs * time.Second) + + (dnsUpdateFudgeSecs * time.Second) + interval = 15 * time.Second + return +} + +// Present creates a TXT record using the specified parameters. +func (p *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + zone, err := p.getHostedZoneInfo(fqdn) + if err != nil { + return err + } + + if _, err = p.linode.CreateDomainResourceTXT(zone.domainId, acme.UnFqdn(fqdn), value, 60); err != nil { + return err + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (p *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + zone, err := p.getHostedZoneInfo(fqdn) + if err != nil { + return err + } + + // Get all TXT records for the specified domain. + resources, err := p.linode.GetResourcesByType(zone.domainId, "TXT") + if err != nil { + return err + } + + // Remove the specified resource, if it exists. + for _, resource := range resources { + if resource.Name == zone.resourceName && resource.Target == value { + resp, err := p.linode.DeleteDomainResource(resource.DomainID, resource.ResourceID) + if err != nil { + return err + } + if resp.ResourceID != resource.ResourceID { + return errors.New("Error deleting resource: resource IDs do not match!") + } + break + } + } + + return nil +} + +func (p *DNSProvider) getHostedZoneInfo(fqdn string) (*hostedZoneInfo, error) { + // Lookup the zone that handles the specified FQDN. + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return nil, err + } + resourceName := strings.TrimSuffix(fqdn, "."+authZone) + + // Query the authority zone. + domain, err := p.linode.GetDomain(acme.UnFqdn(authZone)) + if err != nil { + return nil, err + } + + return &hostedZoneInfo{ + domainId: domain.DomainID, + resourceName: resourceName, + }, nil +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/linode/linode_test.go b/vendor/github.com/xenolf/lego/providers/dns/linode/linode_test.go new file mode 100644 index 000000000..d9713a275 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/linode/linode_test.go @@ -0,0 +1,317 @@ +package linode + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/timewasted/linode" + "github.com/timewasted/linode/dns" +) + +type ( + LinodeResponse struct { + Action string `json:"ACTION"` + Data interface{} `json:"DATA"` + Errors []linode.ResponseError `json:"ERRORARRAY"` + } + MockResponse struct { + Response interface{} + Errors []linode.ResponseError + } + MockResponseMap map[string]MockResponse +) + +var ( + apiKey string + isTestLive bool +) + +func init() { + apiKey = os.Getenv("LINODE_API_KEY") + isTestLive = len(apiKey) != 0 +} + +func restoreEnv() { + os.Setenv("LINODE_API_KEY", apiKey) +} + +func newMockServer(t *testing.T, responses MockResponseMap) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Ensure that we support the requested action. + action := r.URL.Query().Get("api_action") + resp, ok := responses[action] + if !ok { + msg := fmt.Sprintf("Unsupported mock action: %s", action) + require.FailNow(t, msg) + } + + // Build the response that the server will return. + linodeResponse := LinodeResponse{ + Action: action, + Data: resp.Response, + Errors: resp.Errors, + } + rawResponse, err := json.Marshal(linodeResponse) + if err != nil { + msg := fmt.Sprintf("Failed to JSON encode response: %v", err) + require.FailNow(t, msg) + } + + // Send the response. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(rawResponse) + })) + + time.Sleep(100 * time.Millisecond) + return srv +} + +func TestNewDNSProviderWithEnv(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + _, err := NewDNSProvider() + assert.NoError(t, err) +} + +func TestNewDNSProviderWithoutEnv(t *testing.T) { + os.Setenv("LINODE_API_KEY", "") + defer restoreEnv() + _, err := NewDNSProvider() + assert.EqualError(t, err, "Linode credentials missing") +} + +func TestNewDNSProviderCredentialsWithKey(t *testing.T) { + _, err := NewDNSProviderCredentials("testing") + assert.NoError(t, err) +} + +func TestNewDNSProviderCredentialsWithoutKey(t *testing.T) { + _, err := NewDNSProviderCredentials("") + assert.EqualError(t, err, "Linode credentials missing") +} + +func TestDNSProvider_Present(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + p, err := NewDNSProvider() + assert.NoError(t, err) + + domain := "example.com" + keyAuth := "dGVzdGluZw==" + mockResponses := MockResponseMap{ + "domain.list": MockResponse{ + Response: []dns.Domain{ + dns.Domain{ + Domain: domain, + DomainID: 1234, + }, + }, + }, + "domain.resource.create": MockResponse{ + Response: dns.ResourceResponse{ + ResourceID: 1234, + }, + }, + } + mockSrv := newMockServer(t, mockResponses) + defer mockSrv.Close() + p.linode.ToLinode().SetEndpoint(mockSrv.URL) + + err = p.Present(domain, "", keyAuth) + assert.NoError(t, err) +} + +func TestDNSProvider_PresentNoDomain(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + p, err := NewDNSProvider() + assert.NoError(t, err) + + domain := "example.com" + keyAuth := "dGVzdGluZw==" + mockResponses := MockResponseMap{ + "domain.list": MockResponse{ + Response: []dns.Domain{ + dns.Domain{ + Domain: "foobar.com", + DomainID: 1234, + }, + }, + }, + } + mockSrv := newMockServer(t, mockResponses) + defer mockSrv.Close() + p.linode.ToLinode().SetEndpoint(mockSrv.URL) + + err = p.Present(domain, "", keyAuth) + assert.EqualError(t, err, "dns: requested domain not found") +} + +func TestDNSProvider_PresentCreateFailed(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + p, err := NewDNSProvider() + assert.NoError(t, err) + + domain := "example.com" + keyAuth := "dGVzdGluZw==" + mockResponses := MockResponseMap{ + "domain.list": MockResponse{ + Response: []dns.Domain{ + dns.Domain{ + Domain: domain, + DomainID: 1234, + }, + }, + }, + "domain.resource.create": MockResponse{ + Response: nil, + Errors: []linode.ResponseError{ + linode.ResponseError{ + Code: 1234, + Message: "Failed to create domain resource", + }, + }, + }, + } + mockSrv := newMockServer(t, mockResponses) + defer mockSrv.Close() + p.linode.ToLinode().SetEndpoint(mockSrv.URL) + + err = p.Present(domain, "", keyAuth) + assert.EqualError(t, err, "Failed to create domain resource") +} + +func TestDNSProvider_PresentLive(t *testing.T) { + if !isTestLive { + t.Skip("Skipping live test") + } +} + +func TestDNSProvider_CleanUp(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + p, err := NewDNSProvider() + assert.NoError(t, err) + + domain := "example.com" + keyAuth := "dGVzdGluZw==" + mockResponses := MockResponseMap{ + "domain.list": MockResponse{ + Response: []dns.Domain{ + dns.Domain{ + Domain: domain, + DomainID: 1234, + }, + }, + }, + "domain.resource.list": MockResponse{ + Response: []dns.Resource{ + dns.Resource{ + DomainID: 1234, + Name: "_acme-challenge", + ResourceID: 1234, + Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", + Type: "TXT", + }, + }, + }, + "domain.resource.delete": MockResponse{ + Response: dns.ResourceResponse{ + ResourceID: 1234, + }, + }, + } + mockSrv := newMockServer(t, mockResponses) + defer mockSrv.Close() + p.linode.ToLinode().SetEndpoint(mockSrv.URL) + + err = p.CleanUp(domain, "", keyAuth) + assert.NoError(t, err) +} + +func TestDNSProvider_CleanUpNoDomain(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + p, err := NewDNSProvider() + assert.NoError(t, err) + + domain := "example.com" + keyAuth := "dGVzdGluZw==" + mockResponses := MockResponseMap{ + "domain.list": MockResponse{ + Response: []dns.Domain{ + dns.Domain{ + Domain: "foobar.com", + DomainID: 1234, + }, + }, + }, + } + mockSrv := newMockServer(t, mockResponses) + defer mockSrv.Close() + p.linode.ToLinode().SetEndpoint(mockSrv.URL) + + err = p.CleanUp(domain, "", keyAuth) + assert.EqualError(t, err, "dns: requested domain not found") +} + +func TestDNSProvider_CleanUpDeleteFailed(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + p, err := NewDNSProvider() + assert.NoError(t, err) + + domain := "example.com" + keyAuth := "dGVzdGluZw==" + mockResponses := MockResponseMap{ + "domain.list": MockResponse{ + Response: []dns.Domain{ + dns.Domain{ + Domain: domain, + DomainID: 1234, + }, + }, + }, + "domain.resource.list": MockResponse{ + Response: []dns.Resource{ + dns.Resource{ + DomainID: 1234, + Name: "_acme-challenge", + ResourceID: 1234, + Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", + Type: "TXT", + }, + }, + }, + "domain.resource.delete": MockResponse{ + Response: nil, + Errors: []linode.ResponseError{ + linode.ResponseError{ + Code: 1234, + Message: "Failed to delete domain resource", + }, + }, + }, + } + mockSrv := newMockServer(t, mockResponses) + defer mockSrv.Close() + p.linode.ToLinode().SetEndpoint(mockSrv.URL) + + err = p.CleanUp(domain, "", keyAuth) + assert.EqualError(t, err, "Failed to delete domain resource") +} + +func TestDNSProvider_CleanUpLive(t *testing.T) { + if !isTestLive { + t.Skip("Skipping live test") + } +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/namecheap/namecheap.go b/vendor/github.com/xenolf/lego/providers/dns/namecheap/namecheap.go new file mode 100644 index 000000000..d7eb40935 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/namecheap/namecheap.go @@ -0,0 +1,416 @@ +// Package namecheap implements a DNS provider for solving the DNS-01 +// challenge using namecheap DNS. +package namecheap + +import ( + "encoding/xml" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/xenolf/lego/acme" +) + +// Notes about namecheap's tool API: +// 1. Using the API requires registration. Once registered, use your account +// name and API key to access the API. +// 2. There is no API to add or modify a single DNS record. Instead you must +// read the entire list of records, make modifications, and then write the +// entire updated list of records. (Yuck.) +// 3. Namecheap's DNS updates can be slow to propagate. I've seen them take +// as long as an hour. +// 4. Namecheap requires you to whitelist the IP address from which you call +// its APIs. It also requires all API calls to include the whitelisted IP +// address as a form or query string value. This code uses a namecheap +// service to query the client's IP address. + +var ( + debug = false + defaultBaseURL = "https://api.namecheap.com/xml.response" + getIPURL = "https://dynamicdns.park-your-domain.com/getip" + httpClient = http.Client{Timeout: 60 * time.Second} +) + +// DNSProvider is an implementation of the ChallengeProviderTimeout interface +// that uses Namecheap's tool API to manage TXT records for a domain. +type DNSProvider struct { + baseURL string + apiUser string + apiKey string + clientIP string +} + +// NewDNSProvider returns a DNSProvider instance configured for namecheap. +// Credentials must be passed in the environment variables: NAMECHEAP_API_USER +// and NAMECHEAP_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + apiUser := os.Getenv("NAMECHEAP_API_USER") + apiKey := os.Getenv("NAMECHEAP_API_KEY") + return NewDNSProviderCredentials(apiUser, apiKey) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for namecheap. +func NewDNSProviderCredentials(apiUser, apiKey string) (*DNSProvider, error) { + if apiUser == "" || apiKey == "" { + return nil, fmt.Errorf("Namecheap credentials missing") + } + + clientIP, err := getClientIP() + if err != nil { + return nil, err + } + + return &DNSProvider{ + baseURL: defaultBaseURL, + apiUser: apiUser, + apiKey: apiKey, + clientIP: clientIP, + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS +// propagation. Namecheap can sometimes take a long time to complete an +// update, so wait up to 60 minutes for the update to propagate. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return 60 * time.Minute, 15 * time.Second +} + +// host describes a DNS record returned by the Namecheap DNS gethosts API. +// Namecheap uses the term "host" to refer to all DNS records that include +// a host field (A, AAAA, CNAME, NS, TXT, URL). +type host struct { + Type string `xml:",attr"` + Name string `xml:",attr"` + Address string `xml:",attr"` + MXPref string `xml:",attr"` + TTL string `xml:",attr"` +} + +// apierror describes an error record in a namecheap API response. +type apierror struct { + Number int `xml:",attr"` + Description string `xml:",innerxml"` +} + +// getClientIP returns the client's public IP address. It uses namecheap's +// IP discovery service to perform the lookup. +func getClientIP() (addr string, err error) { + resp, err := httpClient.Get(getIPURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + clientIP, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if debug { + fmt.Println("Client IP:", string(clientIP)) + } + return string(clientIP), nil +} + +// A challenge repesents all the data needed to specify a dns-01 challenge +// to lets-encrypt. +type challenge struct { + domain string + key string + keyFqdn string + keyValue string + tld string + sld string + host string +} + +// newChallenge builds a challenge record from a domain name, a challenge +// authentication key, and a map of available TLDs. +func newChallenge(domain, keyAuth string, tlds map[string]string) (*challenge, error) { + domain = acme.UnFqdn(domain) + parts := strings.Split(domain, ".") + + // Find the longest matching TLD. + longest := -1 + for i := len(parts); i > 0; i-- { + t := strings.Join(parts[i-1:], ".") + if _, found := tlds[t]; found { + longest = i - 1 + } + } + if longest < 1 { + return nil, fmt.Errorf("Invalid domain name '%s'", domain) + } + + tld := strings.Join(parts[longest:], ".") + sld := parts[longest-1] + + var host string + if longest >= 1 { + host = strings.Join(parts[:longest-1], ".") + } + + key, keyValue, _ := acme.DNS01Record(domain, keyAuth) + + return &challenge{ + domain: domain, + key: "_acme-challenge." + host, + keyFqdn: key, + keyValue: keyValue, + tld: tld, + sld: sld, + host: host, + }, nil +} + +// setGlobalParams adds the namecheap global parameters to the provided url +// Values record. +func (d *DNSProvider) setGlobalParams(v *url.Values, cmd string) { + v.Set("ApiUser", d.apiUser) + v.Set("ApiKey", d.apiKey) + v.Set("UserName", d.apiUser) + v.Set("ClientIp", d.clientIP) + v.Set("Command", cmd) +} + +// getTLDs requests the list of available TLDs from namecheap. +func (d *DNSProvider) getTLDs() (tlds map[string]string, err error) { + values := make(url.Values) + d.setGlobalParams(&values, "namecheap.domains.getTldList") + + reqURL, _ := url.Parse(d.baseURL) + reqURL.RawQuery = values.Encode() + + resp, err := httpClient.Get(reqURL.String()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("getHosts HTTP error %d", resp.StatusCode) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + type GetTldsResponse struct { + XMLName xml.Name `xml:"ApiResponse"` + Errors []apierror `xml:"Errors>Error"` + Result []struct { + Name string `xml:",attr"` + } `xml:"CommandResponse>Tlds>Tld"` + } + + var gtr GetTldsResponse + if err := xml.Unmarshal(body, >r); err != nil { + return nil, err + } + if len(gtr.Errors) > 0 { + return nil, fmt.Errorf("Namecheap error: %s [%d]", + gtr.Errors[0].Description, gtr.Errors[0].Number) + } + + tlds = make(map[string]string) + for _, t := range gtr.Result { + tlds[t.Name] = t.Name + } + return tlds, nil +} + +// getHosts reads the full list of DNS host records using the Namecheap API. +func (d *DNSProvider) getHosts(ch *challenge) (hosts []host, err error) { + values := make(url.Values) + d.setGlobalParams(&values, "namecheap.domains.dns.getHosts") + values.Set("SLD", ch.sld) + values.Set("TLD", ch.tld) + + reqURL, _ := url.Parse(d.baseURL) + reqURL.RawQuery = values.Encode() + + resp, err := httpClient.Get(reqURL.String()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("getHosts HTTP error %d", resp.StatusCode) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + type GetHostsResponse struct { + XMLName xml.Name `xml:"ApiResponse"` + Status string `xml:"Status,attr"` + Errors []apierror `xml:"Errors>Error"` + Hosts []host `xml:"CommandResponse>DomainDNSGetHostsResult>host"` + } + + var ghr GetHostsResponse + if err = xml.Unmarshal(body, &ghr); err != nil { + return nil, err + } + if len(ghr.Errors) > 0 { + return nil, fmt.Errorf("Namecheap error: %s [%d]", + ghr.Errors[0].Description, ghr.Errors[0].Number) + } + + return ghr.Hosts, nil +} + +// setHosts writes the full list of DNS host records using the Namecheap API. +func (d *DNSProvider) setHosts(ch *challenge, hosts []host) error { + values := make(url.Values) + d.setGlobalParams(&values, "namecheap.domains.dns.setHosts") + values.Set("SLD", ch.sld) + values.Set("TLD", ch.tld) + + for i, h := range hosts { + ind := fmt.Sprintf("%d", i+1) + values.Add("HostName"+ind, h.Name) + values.Add("RecordType"+ind, h.Type) + values.Add("Address"+ind, h.Address) + values.Add("MXPref"+ind, h.MXPref) + values.Add("TTL"+ind, h.TTL) + } + + resp, err := httpClient.PostForm(d.baseURL, values) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("setHosts HTTP error %d", resp.StatusCode) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + type SetHostsResponse struct { + XMLName xml.Name `xml:"ApiResponse"` + Status string `xml:"Status,attr"` + Errors []apierror `xml:"Errors>Error"` + Result struct { + IsSuccess string `xml:",attr"` + } `xml:"CommandResponse>DomainDNSSetHostsResult"` + } + + var shr SetHostsResponse + if err := xml.Unmarshal(body, &shr); err != nil { + return err + } + if len(shr.Errors) > 0 { + return fmt.Errorf("Namecheap error: %s [%d]", + shr.Errors[0].Description, shr.Errors[0].Number) + } + if shr.Result.IsSuccess != "true" { + return fmt.Errorf("Namecheap setHosts failed.") + } + + return nil +} + +// addChallengeRecord adds a DNS challenge TXT record to a list of namecheap +// host records. +func (d *DNSProvider) addChallengeRecord(ch *challenge, hosts *[]host) { + host := host{ + Name: ch.key, + Type: "TXT", + Address: ch.keyValue, + MXPref: "10", + TTL: "120", + } + + // If there's already a TXT record with the same name, replace it. + for i, h := range *hosts { + if h.Name == ch.key && h.Type == "TXT" { + (*hosts)[i] = host + return + } + } + + // No record was replaced, so add a new one. + *hosts = append(*hosts, host) +} + +// removeChallengeRecord removes a DNS challenge TXT record from a list of +// namecheap host records. Return true if a record was removed. +func (d *DNSProvider) removeChallengeRecord(ch *challenge, hosts *[]host) bool { + // Find the challenge TXT record and remove it if found. + for i, h := range *hosts { + if h.Name == ch.key && h.Type == "TXT" { + *hosts = append((*hosts)[:i], (*hosts)[i+1:]...) + return true + } + } + + return false +} + +// Present installs a TXT record for the DNS challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + tlds, err := d.getTLDs() + if err != nil { + return err + } + + ch, err := newChallenge(domain, keyAuth, tlds) + if err != nil { + return err + } + + hosts, err := d.getHosts(ch) + if err != nil { + return err + } + + d.addChallengeRecord(ch, &hosts) + + if debug { + for _, h := range hosts { + fmt.Printf( + "%-5.5s %-30.30s %-6s %-70.70s\n", + h.Type, h.Name, h.TTL, h.Address) + } + } + + return d.setHosts(ch, hosts) +} + +// CleanUp removes a TXT record used for a previous DNS challenge. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + tlds, err := d.getTLDs() + if err != nil { + return err + } + + ch, err := newChallenge(domain, keyAuth, tlds) + if err != nil { + return err + } + + hosts, err := d.getHosts(ch) + if err != nil { + return err + } + + if removed := d.removeChallengeRecord(ch, &hosts); !removed { + return nil + } + + return d.setHosts(ch, hosts) +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/namecheap/namecheap_test.go b/vendor/github.com/xenolf/lego/providers/dns/namecheap/namecheap_test.go new file mode 100644 index 000000000..0631d4a3e --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/namecheap/namecheap_test.go @@ -0,0 +1,402 @@ +package namecheap + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +var ( + fakeUser = "foo" + fakeKey = "bar" + fakeClientIP = "10.0.0.1" + + tlds = map[string]string{ + "com.au": "com.au", + "com": "com", + "co.uk": "co.uk", + "uk": "uk", + "edu": "edu", + "co.com": "co.com", + "za.com": "za.com", + } +) + +func assertEq(t *testing.T, variable, got, want string) { + if got != want { + t.Errorf("Expected %s to be '%s' but got '%s'", variable, want, got) + } +} + +func assertHdr(tc *testcase, t *testing.T, values *url.Values) { + ch, _ := newChallenge(tc.domain, "", tlds) + + assertEq(t, "ApiUser", values.Get("ApiUser"), fakeUser) + assertEq(t, "ApiKey", values.Get("ApiKey"), fakeKey) + assertEq(t, "UserName", values.Get("UserName"), fakeUser) + assertEq(t, "ClientIp", values.Get("ClientIp"), fakeClientIP) + assertEq(t, "SLD", values.Get("SLD"), ch.sld) + assertEq(t, "TLD", values.Get("TLD"), ch.tld) +} + +func mockServer(tc *testcase, t *testing.T, w http.ResponseWriter, r *http.Request) { + switch r.Method { + + case "GET": + values := r.URL.Query() + cmd := values.Get("Command") + switch cmd { + case "namecheap.domains.dns.getHosts": + assertHdr(tc, t, &values) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, tc.getHostsResponse) + case "namecheap.domains.getTldList": + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, responseGetTlds) + default: + t.Errorf("Unexpected GET command: %s", cmd) + } + + case "POST": + r.ParseForm() + values := r.Form + cmd := values.Get("Command") + switch cmd { + case "namecheap.domains.dns.setHosts": + assertHdr(tc, t, &values) + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, tc.setHostsResponse) + default: + t.Errorf("Unexpected POST command: %s", cmd) + } + + default: + t.Errorf("Unexpected http method: %s", r.Method) + + } +} + +func testGetHosts(tc *testcase, t *testing.T) { + mock := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + mockServer(tc, t, w, r) + })) + defer mock.Close() + + prov := &DNSProvider{ + baseURL: mock.URL, + apiUser: fakeUser, + apiKey: fakeKey, + clientIP: fakeClientIP, + } + + ch, _ := newChallenge(tc.domain, "", tlds) + hosts, err := prov.getHosts(ch) + if tc.errString != "" { + if err == nil || err.Error() != tc.errString { + t.Errorf("Namecheap getHosts case %s expected error", tc.name) + } + } else { + if err != nil { + t.Errorf("Namecheap getHosts case %s failed\n%v", tc.name, err) + } + } + +next1: + for _, h := range hosts { + for _, th := range tc.hosts { + if h == th { + continue next1 + } + } + t.Errorf("getHosts case %s unexpected record [%s:%s:%s]", + tc.name, h.Type, h.Name, h.Address) + } + +next2: + for _, th := range tc.hosts { + for _, h := range hosts { + if h == th { + continue next2 + } + } + t.Errorf("getHosts case %s missing record [%s:%s:%s]", + tc.name, th.Type, th.Name, th.Address) + } +} + +func mockDNSProvider(url string) *DNSProvider { + return &DNSProvider{ + baseURL: url, + apiUser: fakeUser, + apiKey: fakeKey, + clientIP: fakeClientIP, + } +} + +func testSetHosts(tc *testcase, t *testing.T) { + mock := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + mockServer(tc, t, w, r) + })) + defer mock.Close() + + prov := mockDNSProvider(mock.URL) + ch, _ := newChallenge(tc.domain, "", tlds) + hosts, err := prov.getHosts(ch) + if tc.errString != "" { + if err == nil || err.Error() != tc.errString { + t.Errorf("Namecheap getHosts case %s expected error", tc.name) + } + } else { + if err != nil { + t.Errorf("Namecheap getHosts case %s failed\n%v", tc.name, err) + } + } + if err != nil { + return + } + + err = prov.setHosts(ch, hosts) + if err != nil { + t.Errorf("Namecheap setHosts case %s failed", tc.name) + } +} + +func testPresent(tc *testcase, t *testing.T) { + mock := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + mockServer(tc, t, w, r) + })) + defer mock.Close() + + prov := mockDNSProvider(mock.URL) + err := prov.Present(tc.domain, "", "dummyKey") + if tc.errString != "" { + if err == nil || err.Error() != tc.errString { + t.Errorf("Namecheap Present case %s expected error", tc.name) + } + } else { + if err != nil { + t.Errorf("Namecheap Present case %s failed\n%v", tc.name, err) + } + } +} + +func testCleanUp(tc *testcase, t *testing.T) { + mock := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + mockServer(tc, t, w, r) + })) + defer mock.Close() + + prov := mockDNSProvider(mock.URL) + err := prov.CleanUp(tc.domain, "", "dummyKey") + if tc.errString != "" { + if err == nil || err.Error() != tc.errString { + t.Errorf("Namecheap CleanUp case %s expected error", tc.name) + } + } else { + if err != nil { + t.Errorf("Namecheap CleanUp case %s failed\n%v", tc.name, err) + } + } +} + +func TestNamecheap(t *testing.T) { + for _, tc := range testcases { + testGetHosts(&tc, t) + testSetHosts(&tc, t) + testPresent(&tc, t) + testCleanUp(&tc, t) + } +} + +func TestNamecheapDomainSplit(t *testing.T) { + tests := []struct { + domain string + valid bool + tld string + sld string + host string + }{ + {"a.b.c.test.co.uk", true, "co.uk", "test", "a.b.c"}, + {"test.co.uk", true, "co.uk", "test", ""}, + {"test.com", true, "com", "test", ""}, + {"test.co.com", true, "co.com", "test", ""}, + {"www.test.com.au", true, "com.au", "test", "www"}, + {"www.za.com", true, "za.com", "www", ""}, + {"", false, "", "", ""}, + {"a", false, "", "", ""}, + {"com", false, "", "", ""}, + {"co.com", false, "", "", ""}, + {"co.uk", false, "", "", ""}, + {"test.au", false, "", "", ""}, + {"za.com", false, "", "", ""}, + {"www.za", false, "", "", ""}, + {"www.test.au", false, "", "", ""}, + {"www.test.unk", false, "", "", ""}, + } + + for _, test := range tests { + valid := true + ch, err := newChallenge(test.domain, "", tlds) + if err != nil { + valid = false + } + + if test.valid && !valid { + t.Errorf("Expected '%s' to split", test.domain) + } else if !test.valid && valid { + t.Errorf("Expected '%s' to produce error", test.domain) + } + + if test.valid && valid { + assertEq(t, "domain", ch.domain, test.domain) + assertEq(t, "tld", ch.tld, test.tld) + assertEq(t, "sld", ch.sld, test.sld) + assertEq(t, "host", ch.host, test.host) + } + } +} + +type testcase struct { + name string + domain string + hosts []host + errString string + getHostsResponse string + setHostsResponse string +} + +var testcases = []testcase{ + { + "Test:Success:1", + "test.example.com", + []host{ + {"A", "home", "10.0.0.1", "10", "1799"}, + {"A", "www", "10.0.0.2", "10", "1200"}, + {"AAAA", "a", "::0", "10", "1799"}, + {"CNAME", "*", "example.com.", "10", "1799"}, + {"MXE", "example.com", "10.0.0.5", "10", "1800"}, + {"URL", "xyz", "https://google.com", "10", "1799"}, + }, + "", + responseGetHostsSuccess1, + responseSetHostsSuccess1, + }, + { + "Test:Success:2", + "example.com", + []host{ + {"A", "@", "10.0.0.2", "10", "1200"}, + {"A", "www", "10.0.0.3", "10", "60"}, + }, + "", + responseGetHostsSuccess2, + responseSetHostsSuccess2, + }, + { + "Test:Error:BadApiKey:1", + "test.example.com", + nil, + "Namecheap error: API Key is invalid or API access has not been enabled [1011102]", + responseGetHostsErrorBadAPIKey1, + "", + }, +} + +var responseGetHostsSuccess1 = ` + + + + namecheap.domains.dns.getHosts + + + + + + + + + + + PHX01SBAPI01 + --5:00 + 3.338 +` + +var responseSetHostsSuccess1 = ` + + + + namecheap.domains.dns.setHosts + + + + + + PHX01SBAPI01 + --5:00 + 2.347 +` + +var responseGetHostsSuccess2 = ` + + + + namecheap.domains.dns.getHosts + + + + + + + PHX01SBAPI01 + --5:00 + 3.338 +` + +var responseSetHostsSuccess2 = ` + + + + namecheap.domains.dns.setHosts + + + + + + PHX01SBAPI01 + --5:00 + 2.347 +` + +var responseGetHostsErrorBadAPIKey1 = ` + + + API Key is invalid or API access has not been enabled + + + + PHX01SBAPI01 + --5:00 + 0 +` + +var responseGetTlds = ` + + + + namecheap.domains.getTldList + + + Most recognized top level domain + + + PHX01SBAPI01 + --5:00 + 0.004 +` diff --git a/vendor/github.com/xenolf/lego/providers/dns/ovh/ovh.go b/vendor/github.com/xenolf/lego/providers/dns/ovh/ovh.go new file mode 100644 index 000000000..290a8d7df --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/ovh/ovh.go @@ -0,0 +1,159 @@ +// Package OVH implements a DNS provider for solving the DNS-01 +// challenge using OVH DNS. +package ovh + +import ( + "fmt" + "os" + "strings" + "sync" + + "github.com/ovh/go-ovh/ovh" + "github.com/xenolf/lego/acme" +) + +// OVH API reference: https://eu.api.ovh.com/ +// Create a Token: https://eu.api.ovh.com/createToken/ + +// DNSProvider is an implementation of the acme.ChallengeProvider interface +// that uses OVH's REST API to manage TXT records for a domain. +type DNSProvider struct { + client *ovh.Client + recordIDs map[string]int + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for OVH +// Credentials must be passed in the environment variable: +// OVH_ENDPOINT : it must be ovh-eu or ovh-ca +// OVH_APPLICATION_KEY +// OVH_APPLICATION_SECRET +// OVH_CONSUMER_KEY +func NewDNSProvider() (*DNSProvider, error) { + apiEndpoint := os.Getenv("OVH_ENDPOINT") + applicationKey := os.Getenv("OVH_APPLICATION_KEY") + applicationSecret := os.Getenv("OVH_APPLICATION_SECRET") + consumerKey := os.Getenv("OVH_CONSUMER_KEY") + return NewDNSProviderCredentials(apiEndpoint, applicationKey, applicationSecret, consumerKey) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for OVH. +func NewDNSProviderCredentials(apiEndpoint, applicationKey, applicationSecret, consumerKey string) (*DNSProvider, error) { + if apiEndpoint == "" || applicationKey == "" || applicationSecret == "" || consumerKey == "" { + return nil, fmt.Errorf("OVH credentials missing") + } + + ovhClient, _ := ovh.NewClient( + apiEndpoint, + applicationKey, + applicationSecret, + consumerKey, + ) + + return &DNSProvider{ + client: ovhClient, + recordIDs: make(map[string]int), + }, nil +} + +// Present creates a TXT record to fulfil the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + + // txtRecordRequest represents the request body to DO's API to make a TXT record + type txtRecordRequest struct { + FieldType string `json:"fieldType"` + SubDomain string `json:"subDomain"` + Target string `json:"target"` + TTL int `json:"ttl"` + } + + // txtRecordResponse represents a response from DO's API after making a TXT record + type txtRecordResponse struct { + ID int `json:"id"` + FieldType string `json:"fieldType"` + SubDomain string `json:"subDomain"` + Target string `json:"target"` + TTL int `json:"ttl"` + Zone string `json:"zone"` + } + + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + + // Parse domain name + authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) + if err != nil { + return fmt.Errorf("Could not determine zone for domain: '%s'. %s", domain, err) + } + + authZone = acme.UnFqdn(authZone) + subDomain := d.extractRecordName(fqdn, authZone) + + reqURL := fmt.Sprintf("/domain/zone/%s/record", authZone) + reqData := txtRecordRequest{FieldType: "TXT", SubDomain: subDomain, Target: value, TTL: ttl} + var respData txtRecordResponse + + // Create TXT record + err = d.client.Post(reqURL, reqData, &respData) + if err != nil { + fmt.Printf("Error when call OVH api to add record : %q \n", err) + return err + } + + // Apply the change + reqURL = fmt.Sprintf("/domain/zone/%s/refresh", authZone) + err = d.client.Post(reqURL, nil, nil) + if err != nil { + fmt.Printf("Error when call OVH api to refresh zone : %q \n", err) + return err + } + + d.recordIDsMu.Lock() + d.recordIDs[fqdn] = respData.ID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := acme.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) + } + + authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) + if err != nil { + return fmt.Errorf("Could not determine zone for domain: '%s'. %s", domain, err) + } + + authZone = acme.UnFqdn(authZone) + + reqURL := fmt.Sprintf("/domain/zone/%s/record/%d", authZone, recordID) + + err = d.client.Delete(reqURL, nil) + if err != nil { + fmt.Printf("Error when call OVH api to delete challenge record : %q \n", err) + return err + } + + // Delete record ID from map + d.recordIDsMu.Lock() + delete(d.recordIDs, fqdn) + d.recordIDsMu.Unlock() + + return nil +} + +func (d *DNSProvider) extractRecordName(fqdn, domain string) string { + name := acme.UnFqdn(fqdn) + if idx := strings.Index(name, "."+domain); idx != -1 { + return name[:idx] + } + return name +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/ovh/ovh_test.go b/vendor/github.com/xenolf/lego/providers/dns/ovh/ovh_test.go new file mode 100644 index 000000000..47da60e57 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/ovh/ovh_test.go @@ -0,0 +1,103 @@ +package ovh + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + liveTest bool + apiEndpoint string + applicationKey string + applicationSecret string + consumerKey string + domain string +) + +func init() { + apiEndpoint = os.Getenv("OVH_ENDPOINT") + applicationKey = os.Getenv("OVH_APPLICATION_KEY") + applicationSecret = os.Getenv("OVH_APPLICATION_SECRET") + consumerKey = os.Getenv("OVH_CONSUMER_KEY") + liveTest = len(apiEndpoint) > 0 && len(applicationKey) > 0 && len(applicationSecret) > 0 && len(consumerKey) > 0 +} + +func restoreEnv() { + os.Setenv("OVH_ENDPOINT", apiEndpoint) + os.Setenv("OVH_APPLICATION_KEY", applicationKey) + os.Setenv("OVH_APPLICATION_SECRET", applicationSecret) + os.Setenv("OVH_CONSUMER_KEY", consumerKey) +} + +func TestNewDNSProviderValidEnv(t *testing.T) { + os.Setenv("OVH_ENDPOINT", "ovh-eu") + os.Setenv("OVH_APPLICATION_KEY", "1234") + os.Setenv("OVH_APPLICATION_SECRET", "5678") + os.Setenv("OVH_CONSUMER_KEY", "abcde") + defer restoreEnv() + _, err := NewDNSProvider() + assert.NoError(t, err) +} + +func TestNewDNSProviderMissingCredErr(t *testing.T) { + os.Setenv("OVH_ENDPOINT", "") + os.Setenv("OVH_APPLICATION_KEY", "1234") + os.Setenv("OVH_APPLICATION_SECRET", "5678") + os.Setenv("OVH_CONSUMER_KEY", "abcde") + defer restoreEnv() + _, err := NewDNSProvider() + assert.EqualError(t, err, "OVH credentials missing") + + os.Setenv("OVH_ENDPOINT", "ovh-eu") + os.Setenv("OVH_APPLICATION_KEY", "") + os.Setenv("OVH_APPLICATION_SECRET", "5678") + os.Setenv("OVH_CONSUMER_KEY", "abcde") + defer restoreEnv() + _, err = NewDNSProvider() + assert.EqualError(t, err, "OVH credentials missing") + + os.Setenv("OVH_ENDPOINT", "ovh-eu") + os.Setenv("OVH_APPLICATION_KEY", "1234") + os.Setenv("OVH_APPLICATION_SECRET", "") + os.Setenv("OVH_CONSUMER_KEY", "abcde") + defer restoreEnv() + _, err = NewDNSProvider() + assert.EqualError(t, err, "OVH credentials missing") + + os.Setenv("OVH_ENDPOINT", "ovh-eu") + os.Setenv("OVH_APPLICATION_KEY", "1234") + os.Setenv("OVH_APPLICATION_SECRET", "5678") + os.Setenv("OVH_CONSUMER_KEY", "") + defer restoreEnv() + _, err = NewDNSProvider() + assert.EqualError(t, err, "OVH credentials missing") +} + +func TestLivePresent(t *testing.T) { + if !liveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.Present(domain, "", "123d==") + assert.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !liveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 1) + + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.CleanUp(domain, "", "123d==") + assert.NoError(t, err) +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/pdns/README.md b/vendor/github.com/xenolf/lego/providers/dns/pdns/README.md new file mode 100644 index 000000000..23abb7669 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/pdns/README.md @@ -0,0 +1,7 @@ +## PowerDNS provider + +Tested and confirmed to work with PowerDNS authoratative server 3.4.8 and 4.0.1. Refer to [PowerDNS documentation](https://doc.powerdns.com/md/httpapi/README/) instructions on how to enable the built-in API interface. + +PowerDNS Notes: +- PowerDNS API does not currently support SSL, therefore you should take care to ensure that traffic between lego and the PowerDNS API is over a trusted network, VPN etc. +- In order to have the SOA serial automatically increment each time the `_acme-challenge` record is added/modified via the API, set `SOA-API-EDIT` to `INCEPTION-INCREMENT` for the zone in the `domainmetadata` table diff --git a/vendor/github.com/xenolf/lego/providers/dns/pdns/pdns.go b/vendor/github.com/xenolf/lego/providers/dns/pdns/pdns.go new file mode 100644 index 000000000..a4fd22b0c --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/pdns/pdns.go @@ -0,0 +1,343 @@ +// Package pdns implements a DNS provider for solving the DNS-01 +// challenge using PowerDNS nameserver. +package pdns + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/xenolf/lego/acme" +) + +// DNSProvider is an implementation of the acme.ChallengeProvider interface +type DNSProvider struct { + apiKey string + host *url.URL + apiVersion int +} + +// NewDNSProvider returns a DNSProvider instance configured for pdns. +// Credentials must be passed in the environment variable: +// PDNS_API_URL and PDNS_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + key := os.Getenv("PDNS_API_KEY") + hostUrl, err := url.Parse(os.Getenv("PDNS_API_URL")) + if err != nil { + return nil, err + } + + return NewDNSProviderCredentials(hostUrl, key) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for pdns. +func NewDNSProviderCredentials(host *url.URL, key string) (*DNSProvider, error) { + if key == "" { + return nil, fmt.Errorf("PDNS API key missing") + } + + if host == nil || host.Host == "" { + return nil, fmt.Errorf("PDNS API URL missing") + } + + provider := &DNSProvider{ + host: host, + apiKey: key, + } + provider.getAPIVersion() + + return provider, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS +// propagation. Adjusting here to cope with spikes in propagation times. +func (c *DNSProvider) Timeout() (timeout, interval time.Duration) { + return 120 * time.Second, 2 * time.Second +} + +// 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) + zone, err := c.getHostedZone(fqdn) + if err != nil { + return err + } + + name := fqdn + + // pre-v1 API wants non-fqdn + if c.apiVersion == 0 { + name = acme.UnFqdn(fqdn) + } + + rec := pdnsRecord{ + Content: "\"" + value + "\"", + Disabled: false, + + // pre-v1 API + Type: "TXT", + Name: name, + TTL: 120, + } + + rrsets := rrSets{ + RRSets: []rrSet{ + rrSet{ + Name: name, + ChangeType: "REPLACE", + Type: "TXT", + Kind: "Master", + TTL: 120, + Records: []pdnsRecord{rec}, + }, + }, + } + + body, err := json.Marshal(rrsets) + if err != nil { + return err + } + + _, err = c.makeRequest("PATCH", zone.URL, bytes.NewReader(body)) + if err != nil { + fmt.Println("here") + 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) + + zone, err := c.getHostedZone(fqdn) + if err != nil { + return err + } + + set, err := c.findTxtRecord(fqdn) + if err != nil { + return err + } + + rrsets := rrSets{ + RRSets: []rrSet{ + rrSet{ + Name: set.Name, + Type: set.Type, + ChangeType: "DELETE", + }, + }, + } + body, err := json.Marshal(rrsets) + if err != nil { + return err + } + + _, err = c.makeRequest("PATCH", zone.URL, bytes.NewReader(body)) + if err != nil { + return err + } + + return nil +} + +func (c *DNSProvider) getHostedZone(fqdn string) (*hostedZone, error) { + var zone hostedZone + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return nil, err + } + + url := "/servers/localhost/zones" + result, err := c.makeRequest("GET", url, nil) + if err != nil { + return nil, err + } + + zones := []hostedZone{} + err = json.Unmarshal(result, &zones) + if err != nil { + return nil, err + } + + url = "" + for _, zone := range zones { + if acme.UnFqdn(zone.Name) == acme.UnFqdn(authZone) { + url = zone.URL + } + } + + result, err = c.makeRequest("GET", url, nil) + if err != nil { + return nil, err + } + + err = json.Unmarshal(result, &zone) + if err != nil { + return nil, err + } + + // convert pre-v1 API result + if len(zone.Records) > 0 { + zone.RRSets = []rrSet{} + for _, record := range zone.Records { + set := rrSet{ + Name: record.Name, + Type: record.Type, + Records: []pdnsRecord{record}, + } + zone.RRSets = append(zone.RRSets, set) + } + } + + return &zone, nil +} + +func (c *DNSProvider) findTxtRecord(fqdn string) (*rrSet, error) { + zone, err := c.getHostedZone(fqdn) + if err != nil { + return nil, err + } + + _, err = c.makeRequest("GET", zone.URL, nil) + if err != nil { + return nil, err + } + + for _, set := range zone.RRSets { + if (set.Name == acme.UnFqdn(fqdn) || set.Name == fqdn) && set.Type == "TXT" { + return &set, nil + } + } + + return nil, fmt.Errorf("No existing record found for %s", fqdn) +} + +func (c *DNSProvider) getAPIVersion() { + type APIVersion struct { + URL string `json:"url"` + Version int `json:"version"` + } + + result, err := c.makeRequest("GET", "/api", nil) + if err != nil { + return + } + + var versions []APIVersion + err = json.Unmarshal(result, &versions) + if err != nil { + return + } + + latestVersion := 0 + for _, v := range versions { + if v.Version > latestVersion { + latestVersion = v.Version + } + } + c.apiVersion = latestVersion +} + +func (c *DNSProvider) makeRequest(method, uri string, body io.Reader) (json.RawMessage, error) { + type APIError struct { + Error string `json:"error"` + } + var path = "" + if c.host.Path != "/" { + path = c.host.Path + } + if c.apiVersion > 0 { + if !strings.HasPrefix(uri, "api/v") { + uri = "/api/v" + strconv.Itoa(c.apiVersion) + uri + } else { + uri = "/" + uri + } + } + url := c.host.Scheme + "://" + c.host.Host + path + uri + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + + req.Header.Set("X-API-Key", c.apiKey) + + client := http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("Error talking to PDNS API -> %v", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != 422 && (resp.StatusCode < 200 || resp.StatusCode >= 300) { + return nil, fmt.Errorf("Unexpected HTTP status code %d when fetching '%s'", resp.StatusCode, url) + } + + var msg json.RawMessage + err = json.NewDecoder(resp.Body).Decode(&msg) + switch { + case err == io.EOF: + // empty body + return nil, nil + case err != nil: + // other error + return nil, err + } + + // check for PowerDNS error message + if len(msg) > 0 && msg[0] == '{' { + var apiError APIError + err = json.Unmarshal(msg, &apiError) + if err != nil { + return nil, err + } + if apiError.Error != "" { + return nil, fmt.Errorf("Error talking to PDNS API -> %v", apiError.Error) + } + } + return msg, nil +} + +type pdnsRecord struct { + Content string `json:"content"` + Disabled bool `json:"disabled"` + + // pre-v1 API + Name string `json:"name"` + Type string `json:"type"` + TTL int `json:"ttl,omitempty"` +} + +type hostedZone struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + RRSets []rrSet `json:"rrsets"` + + // pre-v1 API + Records []pdnsRecord `json:"records"` +} + +type rrSet struct { + Name string `json:"name"` + Type string `json:"type"` + Kind string `json:"kind"` + ChangeType string `json:"changetype"` + Records []pdnsRecord `json:"records"` + TTL int `json:"ttl,omitempty"` +} + +type rrSets struct { + RRSets []rrSet `json:"rrsets"` +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/pdns/pdns_test.go b/vendor/github.com/xenolf/lego/providers/dns/pdns/pdns_test.go new file mode 100644 index 000000000..70e7670ed --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/pdns/pdns_test.go @@ -0,0 +1,80 @@ +package pdns + +import ( + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + pdnsLiveTest bool + pdnsURL *url.URL + pdnsURLStr string + pdnsAPIKey string + pdnsDomain string +) + +func init() { + pdnsURLStr = os.Getenv("PDNS_API_URL") + pdnsURL, _ = url.Parse(pdnsURLStr) + pdnsAPIKey = os.Getenv("PDNS_API_KEY") + pdnsDomain = os.Getenv("PDNS_DOMAIN") + if len(pdnsURLStr) > 0 && len(pdnsAPIKey) > 0 && len(pdnsDomain) > 0 { + pdnsLiveTest = true + } +} + +func restorePdnsEnv() { + os.Setenv("PDNS_API_URL", pdnsURLStr) + os.Setenv("PDNS_API_KEY", pdnsAPIKey) +} + +func TestNewDNSProviderValid(t *testing.T) { + os.Setenv("PDNS_API_URL", "") + os.Setenv("PDNS_API_KEY", "") + tmpURL, _ := url.Parse("http://localhost:8081") + _, err := NewDNSProviderCredentials(tmpURL, "123") + assert.NoError(t, err) + restorePdnsEnv() +} + +func TestNewDNSProviderValidEnv(t *testing.T) { + os.Setenv("PDNS_API_URL", "http://localhost:8081") + os.Setenv("PDNS_API_KEY", "123") + _, err := NewDNSProvider() + assert.NoError(t, err) + restorePdnsEnv() +} + +func TestNewDNSProviderMissingHostErr(t *testing.T) { + os.Setenv("PDNS_API_URL", "") + os.Setenv("PDNS_API_KEY", "123") + _, err := NewDNSProvider() + assert.EqualError(t, err, "PDNS API URL missing") + restorePdnsEnv() +} + +func TestNewDNSProviderMissingKeyErr(t *testing.T) { + os.Setenv("PDNS_API_URL", pdnsURLStr) + os.Setenv("PDNS_API_KEY", "") + _, err := NewDNSProvider() + assert.EqualError(t, err, "PDNS API key missing") + restorePdnsEnv() +} + +func TestPdnsPresentAndCleanup(t *testing.T) { + if !pdnsLiveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProviderCredentials(pdnsURL, pdnsAPIKey) + assert.NoError(t, err) + + err = provider.Present(pdnsDomain, "", "123d==") + assert.NoError(t, err) + + err = provider.CleanUp(pdnsDomain, "", "123d==") + assert.NoError(t, err) +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/rfc2136/rfc2136.go b/vendor/github.com/xenolf/lego/providers/dns/rfc2136/rfc2136.go new file mode 100644 index 000000000..43a95f18c --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/rfc2136/rfc2136.go @@ -0,0 +1,129 @@ +// Package rfc2136 implements a DNS provider for solving the DNS-01 challenge +// using the rfc2136 dynamic update. +package rfc2136 + +import ( + "fmt" + "net" + "os" + "strings" + "time" + + "github.com/miekg/dns" + "github.com/xenolf/lego/acme" +) + +// DNSProvider is an implementation of the acme.ChallengeProvider interface that +// uses dynamic DNS updates (RFC 2136) to create TXT records on a nameserver. +type DNSProvider struct { + nameserver string + tsigAlgorithm string + tsigKey string + tsigSecret string +} + +// NewDNSProvider returns a DNSProvider instance configured for rfc2136 +// dynamic update. Credentials must be passed in the environment variables: +// RFC2136_NAMESERVER, RFC2136_TSIG_ALGORITHM, RFC2136_TSIG_KEY and +// RFC2136_TSIG_SECRET. To disable TSIG authentication, leave the TSIG +// variables unset. RFC2136_NAMESERVER must be a network address in the form +// "host" or "host:port". +func NewDNSProvider() (*DNSProvider, error) { + nameserver := os.Getenv("RFC2136_NAMESERVER") + tsigAlgorithm := os.Getenv("RFC2136_TSIG_ALGORITHM") + tsigKey := os.Getenv("RFC2136_TSIG_KEY") + tsigSecret := os.Getenv("RFC2136_TSIG_SECRET") + return NewDNSProviderCredentials(nameserver, tsigAlgorithm, tsigKey, tsigSecret) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for rfc2136 dynamic update. To disable TSIG +// authentication, leave the TSIG parameters as empty strings. +// nameserver must be a network address in the form "host" or "host:port". +func NewDNSProviderCredentials(nameserver, tsigAlgorithm, tsigKey, tsigSecret string) (*DNSProvider, error) { + if nameserver == "" { + return nil, fmt.Errorf("RFC2136 nameserver missing") + } + + // Append the default DNS port if none is specified. + if _, _, err := net.SplitHostPort(nameserver); err != nil { + if strings.Contains(err.Error(), "missing port") { + nameserver = net.JoinHostPort(nameserver, "53") + } else { + return nil, err + } + } + d := &DNSProvider{ + nameserver: nameserver, + } + if tsigAlgorithm == "" { + tsigAlgorithm = dns.HmacMD5 + } + d.tsigAlgorithm = tsigAlgorithm + if len(tsigKey) > 0 && len(tsigSecret) > 0 { + d.tsigKey = tsigKey + d.tsigSecret = tsigSecret + } + + return d, nil +} + +// Present creates a TXT record using the specified parameters +func (r *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + return r.changeRecord("INSERT", fqdn, value, ttl) +} + +// CleanUp removes the TXT record matching the specified parameters +func (r *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + return r.changeRecord("REMOVE", fqdn, value, ttl) +} + +func (r *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { + // Find the zone for the given fqdn + zone, err := acme.FindZoneByFqdn(fqdn, []string{r.nameserver}) + if err != nil { + return err + } + + // Create RR + rr := new(dns.TXT) + rr.Hdr = dns.RR_Header{Name: fqdn, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: uint32(ttl)} + rr.Txt = []string{value} + rrs := []dns.RR{rr} + + // Create dynamic update packet + m := new(dns.Msg) + m.SetUpdate(zone) + switch action { + case "INSERT": + // Always remove old challenge left over from who knows what. + m.RemoveRRset(rrs) + m.Insert(rrs) + case "REMOVE": + m.Remove(rrs) + default: + return fmt.Errorf("Unexpected action: %s", action) + } + + // Setup client + c := new(dns.Client) + c.SingleInflight = true + // TSIG authentication / msg signing + if len(r.tsigKey) > 0 && len(r.tsigSecret) > 0 { + m.SetTsig(dns.Fqdn(r.tsigKey), r.tsigAlgorithm, 300, time.Now().Unix()) + c.TsigSecret = map[string]string{dns.Fqdn(r.tsigKey): r.tsigSecret} + } + + // Send the query + reply, _, err := c.Exchange(m, r.nameserver) + if err != nil { + return fmt.Errorf("DNS update failed: %v", err) + } + if reply != nil && reply.Rcode != dns.RcodeSuccess { + return fmt.Errorf("DNS update failed. Server replied: %s", dns.RcodeToString[reply.Rcode]) + } + + return nil +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/rfc2136/rfc2136_test.go b/vendor/github.com/xenolf/lego/providers/dns/rfc2136/rfc2136_test.go new file mode 100644 index 000000000..a2515e995 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/rfc2136/rfc2136_test.go @@ -0,0 +1,244 @@ +package rfc2136 + +import ( + "bytes" + "fmt" + "net" + "strings" + "sync" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/xenolf/lego/acme" +) + +var ( + rfc2136TestDomain = "123456789.www.example.com" + rfc2136TestKeyAuth = "123d==" + rfc2136TestValue = "Now36o-3BmlB623-0c1qCIUmgWVVmDJb88KGl24pqpo" + rfc2136TestFqdn = "_acme-challenge.123456789.www.example.com." + rfc2136TestZone = "example.com." + rfc2136TestTTL = 120 + rfc2136TestTsigKey = "example.com." + rfc2136TestTsigSecret = "IwBTJx9wrDp4Y1RyC3H0gA==" +) + +var reqChan = make(chan *dns.Msg, 10) + +func TestRFC2136CanaryLocalTestServer(t *testing.T) { + acme.ClearFqdnCache() + dns.HandleFunc("example.com.", serverHandlerHello) + defer dns.HandleRemove("example.com.") + + server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", false) + if err != nil { + t.Fatalf("Failed to start test server: %v", err) + } + defer server.Shutdown() + + c := new(dns.Client) + m := new(dns.Msg) + 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: %v", err) + } + txt := r.Extra[0].(*dns.TXT).Txt[0] + if txt != "Hello world" { + t.Error("Expected test server to return 'Hello world' but got: ", txt) + } +} + +func TestRFC2136ServerSuccess(t *testing.T) { + acme.ClearFqdnCache() + dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess) + defer dns.HandleRemove(rfc2136TestZone) + + server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", false) + if err != nil { + t.Fatalf("Failed to start test server: %v", err) + } + defer server.Shutdown() + + provider, err := NewDNSProviderCredentials(addrstr, "", "", "") + if err != nil { + t.Fatalf("Expected NewDNSProviderCredentials() to return no error but the error was -> %v", err) + } + if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err != nil { + t.Errorf("Expected Present() to return no error but the error was -> %v", err) + } +} + +func TestRFC2136ServerError(t *testing.T) { + acme.ClearFqdnCache() + dns.HandleFunc(rfc2136TestZone, serverHandlerReturnErr) + defer dns.HandleRemove(rfc2136TestZone) + + server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", false) + if err != nil { + t.Fatalf("Failed to start test server: %v", err) + } + defer server.Shutdown() + + provider, err := NewDNSProviderCredentials(addrstr, "", "", "") + if err != nil { + t.Fatalf("Expected NewDNSProviderCredentials() to return no error but the error was -> %v", err) + } + if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err == nil { + t.Errorf("Expected Present() to return an error but it did not.") + } else if !strings.Contains(err.Error(), "NOTZONE") { + t.Errorf("Expected Present() to return an error with the 'NOTZONE' rcode string but it did not.") + } +} + +func TestRFC2136TsigClient(t *testing.T) { + acme.ClearFqdnCache() + dns.HandleFunc(rfc2136TestZone, serverHandlerReturnSuccess) + defer dns.HandleRemove(rfc2136TestZone) + + server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", true) + if err != nil { + t.Fatalf("Failed to start test server: %v", err) + } + defer server.Shutdown() + + provider, err := NewDNSProviderCredentials(addrstr, "", rfc2136TestTsigKey, rfc2136TestTsigSecret) + if err != nil { + t.Fatalf("Expected NewDNSProviderCredentials() to return no error but the error was -> %v", err) + } + if err := provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth); err != nil { + t.Errorf("Expected Present() to return no error but the error was -> %v", err) + } +} + +func TestRFC2136ValidUpdatePacket(t *testing.T) { + acme.ClearFqdnCache() + dns.HandleFunc(rfc2136TestZone, serverHandlerPassBackRequest) + defer dns.HandleRemove(rfc2136TestZone) + + server, addrstr, err := runLocalDNSTestServer("127.0.0.1:0", false) + if err != nil { + t.Fatalf("Failed to start test server: %v", err) + } + defer server.Shutdown() + + txtRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN TXT %s", rfc2136TestFqdn, rfc2136TestTTL, rfc2136TestValue)) + rrs := []dns.RR{txtRR} + m := new(dns.Msg) + m.SetUpdate(rfc2136TestZone) + m.RemoveRRset(rrs) + m.Insert(rrs) + expectstr := m.String() + expect, err := m.Pack() + if err != nil { + t.Fatalf("Error packing expect msg: %v", err) + } + + provider, err := NewDNSProviderCredentials(addrstr, "", "", "") + if err != nil { + t.Fatalf("Expected NewDNSProviderCredentials() to return no error but the error was -> %v", err) + } + + if err := provider.Present(rfc2136TestDomain, "", "1234d=="); err != nil { + t.Errorf("Expected Present() to return no error but the error was -> %v", err) + } + + rcvMsg := <-reqChan + rcvMsg.Id = m.Id + actual, err := rcvMsg.Pack() + if err != nil { + t.Fatalf("Error packing actual msg: %v", err) + } + + if !bytes.Equal(actual, expect) { + tmp := new(dns.Msg) + if err := tmp.Unpack(actual); err != nil { + t.Fatalf("Error unpacking actual msg: %v", err) + } + t.Errorf("Expected msg:\n%s", expectstr) + t.Errorf("Actual msg:\n%v", tmp) + } +} + +func runLocalDNSTestServer(listenAddr string, tsig bool) (*dns.Server, string, error) { + pc, err := net.ListenPacket("udp", listenAddr) + if err != nil { + return nil, "", err + } + server := &dns.Server{PacketConn: pc, ReadTimeout: time.Hour, WriteTimeout: time.Hour} + if tsig { + server.TsigSecret = map[string]string{rfc2136TestTsigKey: rfc2136TestTsigSecret} + } + + waitLock := sync.Mutex{} + waitLock.Lock() + server.NotifyStartedFunc = waitLock.Unlock + + go func() { + server.ActivateAndServe() + pc.Close() + }() + + waitLock.Lock() + return server, pc.LocalAddr().String(), nil +} + +func serverHandlerHello(w dns.ResponseWriter, req *dns.Msg) { + m := new(dns.Msg) + m.SetReply(req) + m.Extra = make([]dns.RR, 1) + m.Extra[0] = &dns.TXT{ + Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 0}, + Txt: []string{"Hello world"}, + } + w.WriteMsg(m) +} + +func serverHandlerReturnSuccess(w dns.ResponseWriter, req *dns.Msg) { + m := new(dns.Msg) + m.SetReply(req) + if req.Opcode == dns.OpcodeQuery && req.Question[0].Qtype == dns.TypeSOA && req.Question[0].Qclass == dns.ClassINET { + // Return SOA to appease findZoneByFqdn() + soaRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN SOA ns1.%s admin.%s 2016022801 28800 7200 2419200 1200", rfc2136TestZone, rfc2136TestTTL, rfc2136TestZone, rfc2136TestZone)) + m.Answer = []dns.RR{soaRR} + } + + if t := req.IsTsig(); t != nil { + if w.TsigStatus() == nil { + // Validated + m.SetTsig(rfc2136TestZone, dns.HmacMD5, 300, time.Now().Unix()) + } + } + + w.WriteMsg(m) +} + +func serverHandlerReturnErr(w dns.ResponseWriter, req *dns.Msg) { + m := new(dns.Msg) + m.SetRcode(req, dns.RcodeNotZone) + w.WriteMsg(m) +} + +func serverHandlerPassBackRequest(w dns.ResponseWriter, req *dns.Msg) { + m := new(dns.Msg) + m.SetReply(req) + if req.Opcode == dns.OpcodeQuery && req.Question[0].Qtype == dns.TypeSOA && req.Question[0].Qclass == dns.ClassINET { + // Return SOA to appease findZoneByFqdn() + soaRR, _ := dns.NewRR(fmt.Sprintf("%s %d IN SOA ns1.%s admin.%s 2016022801 28800 7200 2419200 1200", rfc2136TestZone, rfc2136TestTTL, rfc2136TestZone, rfc2136TestZone)) + m.Answer = []dns.RR{soaRR} + } + + if t := req.IsTsig(); t != nil { + if w.TsigStatus() == nil { + // Validated + m.SetTsig(rfc2136TestZone, dns.HmacMD5, 300, time.Now().Unix()) + } + } + + w.WriteMsg(m) + if req.Opcode != dns.OpcodeQuery || req.Question[0].Qtype != dns.TypeSOA || req.Question[0].Qclass != dns.ClassINET { + // Only talk back when it is not the SOA RR. + reqChan <- req + } +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/route53/fixtures_test.go b/vendor/github.com/xenolf/lego/providers/dns/route53/fixtures_test.go new file mode 100644 index 000000000..a5cc9c878 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/route53/fixtures_test.go @@ -0,0 +1,39 @@ +package route53 + +var ChangeResourceRecordSetsResponse = ` + + + /change/123456 + PENDING + 2016-02-10T01:36:41.958Z + +` + +var ListHostedZonesByNameResponse = ` + + + + /hostedzone/ABCDEFG + example.com. + D2224C5B-684A-DB4A-BB9A-E09E3BAFEA7A + + Test comment + false + + 10 + + + true + example2.com + ZLT12321321124 + 1 +` + +var GetChangeResponse = ` + + + 123456 + INSYNC + 2016-02-10T01:36:41.958Z + +` diff --git a/vendor/github.com/xenolf/lego/providers/dns/route53/route53.go b/vendor/github.com/xenolf/lego/providers/dns/route53/route53.go new file mode 100644 index 000000000..f3e53a8e5 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/route53/route53.go @@ -0,0 +1,171 @@ +// Package route53 implements a DNS provider for solving the DNS-01 challenge +// using AWS Route 53 DNS. +package route53 + +import ( + "fmt" + "math/rand" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/client" + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/route53" + "github.com/xenolf/lego/acme" +) + +const ( + maxRetries = 5 + route53TTL = 10 +) + +// DNSProvider implements the acme.ChallengeProvider interface +type DNSProvider struct { + client *route53.Route53 +} + +// customRetryer implements the client.Retryer interface by composing the +// DefaultRetryer. It controls the logic for retrying recoverable request +// errors (e.g. when rate limits are exceeded). +type customRetryer struct { + client.DefaultRetryer +} + +// RetryRules overwrites the DefaultRetryer's method. +// It uses a basic exponential backoff algorithm that returns an initial +// delay of ~400ms with an upper limit of ~30 seconds which should prevent +// causing a high number of consecutive throttling errors. +// For reference: Route 53 enforces an account-wide(!) 5req/s query limit. +func (d customRetryer) RetryRules(r *request.Request) time.Duration { + retryCount := r.RetryCount + if retryCount > 7 { + retryCount = 7 + } + + delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200) + return time.Duration(delay) * time.Millisecond +} + +// NewDNSProvider returns a DNSProvider instance configured for the AWS +// Route 53 service. +// +// AWS Credentials are automatically detected in the following locations +// and prioritized in the following order: +// 1. Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, +// AWS_REGION, [AWS_SESSION_TOKEN] +// 2. Shared credentials file (defaults to ~/.aws/credentials) +// 3. Amazon EC2 IAM role +// +// See also: https://github.com/aws/aws-sdk-go/wiki/configuring-sdk +func NewDNSProvider() (*DNSProvider, error) { + r := customRetryer{} + r.NumMaxRetries = maxRetries + config := request.WithRetryer(aws.NewConfig(), r) + client := route53.New(session.New(config)) + + return &DNSProvider{client: client}, nil +} + +// Present creates a TXT record using the specified parameters +func (r *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + value = `"` + value + `"` + return r.changeRecord("UPSERT", fqdn, value, route53TTL) +} + +// CleanUp removes the TXT record matching the specified parameters +func (r *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + value = `"` + value + `"` + return r.changeRecord("DELETE", fqdn, value, route53TTL) +} + +func (r *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { + hostedZoneID, err := getHostedZoneID(fqdn, r.client) + if err != nil { + return fmt.Errorf("Failed to determine Route 53 hosted zone ID: %v", err) + } + + recordSet := newTXTRecordSet(fqdn, value, ttl) + reqParams := &route53.ChangeResourceRecordSetsInput{ + HostedZoneId: aws.String(hostedZoneID), + ChangeBatch: &route53.ChangeBatch{ + Comment: aws.String("Managed by Lego"), + Changes: []*route53.Change{ + { + Action: aws.String(action), + ResourceRecordSet: recordSet, + }, + }, + }, + } + + resp, err := r.client.ChangeResourceRecordSets(reqParams) + if err != nil { + return fmt.Errorf("Failed to change Route 53 record set: %v", err) + } + + statusID := resp.ChangeInfo.Id + + return acme.WaitFor(120*time.Second, 4*time.Second, func() (bool, error) { + reqParams := &route53.GetChangeInput{ + Id: statusID, + } + resp, err := r.client.GetChange(reqParams) + if err != nil { + return false, fmt.Errorf("Failed to query Route 53 change status: %v", err) + } + if *resp.ChangeInfo.Status == route53.ChangeStatusInsync { + return true, nil + } + return false, nil + }) +} + +func getHostedZoneID(fqdn string, client *route53.Route53) (string, error) { + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return "", err + } + + // .DNSName should not have a trailing dot + reqParams := &route53.ListHostedZonesByNameInput{ + DNSName: aws.String(acme.UnFqdn(authZone)), + } + resp, err := client.ListHostedZonesByName(reqParams) + if err != nil { + return "", err + } + + var hostedZoneID string + for _, hostedZone := range resp.HostedZones { + // .Name has a trailing dot + if !*hostedZone.Config.PrivateZone && *hostedZone.Name == authZone { + hostedZoneID = *hostedZone.Id + break + } + } + + if len(hostedZoneID) == 0 { + return "", fmt.Errorf("Zone %s not found in Route 53 for domain %s", authZone, fqdn) + } + + if strings.HasPrefix(hostedZoneID, "/hostedzone/") { + hostedZoneID = strings.TrimPrefix(hostedZoneID, "/hostedzone/") + } + + return hostedZoneID, nil +} + +func newTXTRecordSet(fqdn, value string, ttl int) *route53.ResourceRecordSet { + return &route53.ResourceRecordSet{ + Name: aws.String(fqdn), + Type: aws.String("TXT"), + TTL: aws.Int64(int64(ttl)), + ResourceRecords: []*route53.ResourceRecord{ + {Value: aws.String(value)}, + }, + } +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/route53/route53_integration_test.go b/vendor/github.com/xenolf/lego/providers/dns/route53/route53_integration_test.go new file mode 100644 index 000000000..64678906a --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/route53/route53_integration_test.go @@ -0,0 +1,70 @@ +package route53 + +import ( + "fmt" + "os" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/route53" +) + +func TestRoute53TTL(t *testing.T) { + + m, err := testGetAndPreCheck() + if err != nil { + t.Skip(err.Error()) + } + + provider, err := NewDNSProvider() + if err != nil { + t.Fatalf("Fatal: %s", err.Error()) + } + + err = provider.Present(m["route53Domain"], "foo", "bar") + if err != nil { + t.Fatalf("Fatal: %s", err.Error()) + } + // we need a separate R53 client here as the one in the DNS provider is + // unexported. + fqdn := "_acme-challenge." + m["route53Domain"] + "." + svc := route53.New(session.New()) + zoneID, err := getHostedZoneID(fqdn, svc) + if err != nil { + provider.CleanUp(m["route53Domain"], "foo", "bar") + t.Fatalf("Fatal: %s", err.Error()) + } + params := &route53.ListResourceRecordSetsInput{ + HostedZoneId: aws.String(zoneID), + } + resp, err := svc.ListResourceRecordSets(params) + if err != nil { + provider.CleanUp(m["route53Domain"], "foo", "bar") + t.Fatalf("Fatal: %s", err.Error()) + } + + for _, v := range resp.ResourceRecordSets { + if *v.Name == fqdn && *v.Type == "TXT" && *v.TTL == 10 { + provider.CleanUp(m["route53Domain"], "foo", "bar") + return + } + } + provider.CleanUp(m["route53Domain"], "foo", "bar") + t.Fatalf("Could not find a TXT record for _acme-challenge.%s with a TTL of 10", m["route53Domain"]) +} + +func testGetAndPreCheck() (map[string]string, error) { + m := map[string]string{ + "route53Key": os.Getenv("AWS_ACCESS_KEY_ID"), + "route53Secret": os.Getenv("AWS_SECRET_ACCESS_KEY"), + "route53Region": os.Getenv("AWS_REGION"), + "route53Domain": os.Getenv("R53_DOMAIN"), + } + for _, v := range m { + if v == "" { + return nil, fmt.Errorf("AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, and R53_DOMAIN are needed to run this test") + } + } + return m, nil +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/route53/route53_test.go b/vendor/github.com/xenolf/lego/providers/dns/route53/route53_test.go new file mode 100644 index 000000000..ab8739a58 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/route53/route53_test.go @@ -0,0 +1,87 @@ +package route53 + +import ( + "net/http/httptest" + "os" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/route53" + "github.com/stretchr/testify/assert" +) + +var ( + route53Secret string + route53Key string + route53Region string +) + +func init() { + route53Key = os.Getenv("AWS_ACCESS_KEY_ID") + route53Secret = os.Getenv("AWS_SECRET_ACCESS_KEY") + route53Region = os.Getenv("AWS_REGION") +} + +func restoreRoute53Env() { + os.Setenv("AWS_ACCESS_KEY_ID", route53Key) + os.Setenv("AWS_SECRET_ACCESS_KEY", route53Secret) + os.Setenv("AWS_REGION", route53Region) +} + +func makeRoute53Provider(ts *httptest.Server) *DNSProvider { + config := &aws.Config{ + Credentials: credentials.NewStaticCredentials("abc", "123", " "), + Endpoint: aws.String(ts.URL), + Region: aws.String("mock-region"), + MaxRetries: aws.Int(1), + } + + client := route53.New(session.New(config)) + return &DNSProvider{client: client} +} + +func TestCredentialsFromEnv(t *testing.T) { + os.Setenv("AWS_ACCESS_KEY_ID", "123") + os.Setenv("AWS_SECRET_ACCESS_KEY", "123") + os.Setenv("AWS_REGION", "us-east-1") + + config := &aws.Config{ + CredentialsChainVerboseErrors: aws.Bool(true), + } + + sess := session.New(config) + _, err := sess.Config.Credentials.Get() + assert.NoError(t, err, "Expected credentials to be set from environment") + + restoreRoute53Env() +} + +func TestRegionFromEnv(t *testing.T) { + os.Setenv("AWS_REGION", "us-east-1") + + sess := session.New(aws.NewConfig()) + assert.Equal(t, "us-east-1", *sess.Config.Region, "Expected Region to be set from environment") + + restoreRoute53Env() +} + +func TestRoute53Present(t *testing.T) { + mockResponses := MockResponseMap{ + "/2013-04-01/hostedzonesbyname": MockResponse{StatusCode: 200, Body: ListHostedZonesByNameResponse}, + "/2013-04-01/hostedzone/ABCDEFG/rrset/": MockResponse{StatusCode: 200, Body: ChangeResourceRecordSetsResponse}, + "/2013-04-01/change/123456": MockResponse{StatusCode: 200, Body: GetChangeResponse}, + } + + ts := newMockServer(t, mockResponses) + defer ts.Close() + + provider := makeRoute53Provider(ts) + + domain := "example.com" + keyAuth := "123456d==" + + err := provider.Present(domain, "", keyAuth) + assert.NoError(t, err, "Expected Present to return no error") +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/route53/testutil_test.go b/vendor/github.com/xenolf/lego/providers/dns/route53/testutil_test.go new file mode 100644 index 000000000..e448a6858 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/route53/testutil_test.go @@ -0,0 +1,38 @@ +package route53 + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// MockResponse represents a predefined response used by a mock server +type MockResponse struct { + StatusCode int + Body string +} + +// MockResponseMap maps request paths to responses +type MockResponseMap map[string]MockResponse + +func newMockServer(t *testing.T, responses MockResponseMap) *httptest.Server { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + resp, ok := responses[path] + if !ok { + msg := fmt.Sprintf("Requested path not found in response map: %s", path) + require.FailNow(t, msg) + } + + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(resp.StatusCode) + w.Write([]byte(resp.Body)) + })) + + time.Sleep(100 * time.Millisecond) + return ts +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/vultr/vultr.go b/vendor/github.com/xenolf/lego/providers/dns/vultr/vultr.go new file mode 100644 index 000000000..53804e270 --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/vultr/vultr.go @@ -0,0 +1,127 @@ +// Package vultr implements a DNS provider for solving the DNS-01 challenge using +// the vultr DNS. +// See https://www.vultr.com/api/#dns +package vultr + +import ( + "fmt" + "os" + "strings" + + vultr "github.com/JamesClonk/vultr/lib" + "github.com/xenolf/lego/acme" +) + +// DNSProvider is an implementation of the acme.ChallengeProvider interface. +type DNSProvider struct { + client *vultr.Client +} + +// NewDNSProvider returns a DNSProvider instance with a configured Vultr client. +// Authentication uses the VULTR_API_KEY environment variable. +func NewDNSProvider() (*DNSProvider, error) { + apiKey := os.Getenv("VULTR_API_KEY") + return NewDNSProviderCredentials(apiKey) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a DNSProvider +// instance configured for Vultr. +func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { + if apiKey == "" { + return nil, fmt.Errorf("Vultr credentials missing") + } + + c := &DNSProvider{ + client: vultr.NewClient(apiKey, nil), + } + + return c, nil +} + +// Present creates a TXT record to fulfil the DNS-01 challenge. +func (c *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + + zoneDomain, err := c.getHostedZone(domain) + if err != nil { + return err + } + + name := c.extractRecordName(fqdn, zoneDomain) + + err = c.client.CreateDnsRecord(zoneDomain, name, "TXT", `"`+value+`"`, 0, ttl) + if err != nil { + return fmt.Errorf("Vultr API call failed: %v", 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) + + zoneDomain, records, err := c.findTxtRecords(domain, fqdn) + if err != nil { + return err + } + + for _, rec := range records { + err := c.client.DeleteDnsRecord(zoneDomain, rec.RecordID) + if err != nil { + return err + } + } + return nil +} + +func (c *DNSProvider) getHostedZone(domain string) (string, error) { + domains, err := c.client.GetDnsDomains() + if err != nil { + return "", fmt.Errorf("Vultr API call failed: %v", err) + } + + var hostedDomain vultr.DnsDomain + for _, d := range domains { + if strings.HasSuffix(domain, d.Domain) { + if len(d.Domain) > len(hostedDomain.Domain) { + hostedDomain = d + } + } + } + if hostedDomain.Domain == "" { + return "", fmt.Errorf("No matching Vultr domain found for domain %s", domain) + } + + return hostedDomain.Domain, nil +} + +func (c *DNSProvider) findTxtRecords(domain, fqdn string) (string, []vultr.DnsRecord, error) { + zoneDomain, err := c.getHostedZone(domain) + if err != nil { + return "", nil, err + } + + var records []vultr.DnsRecord + result, err := c.client.GetDnsRecords(zoneDomain) + if err != nil { + return "", records, fmt.Errorf("Vultr API call has failed: %v", err) + } + + recordName := c.extractRecordName(fqdn, zoneDomain) + for _, record := range result { + if record.Type == "TXT" && record.Name == recordName { + records = append(records, record) + } + } + + return zoneDomain, records, nil +} + +func (c *DNSProvider) extractRecordName(fqdn, domain string) string { + name := acme.UnFqdn(fqdn) + if idx := strings.Index(name, "."+domain); idx != -1 { + return name[:idx] + } + return name +} diff --git a/vendor/github.com/xenolf/lego/providers/dns/vultr/vultr_test.go b/vendor/github.com/xenolf/lego/providers/dns/vultr/vultr_test.go new file mode 100644 index 000000000..7c8cdaf1e --- /dev/null +++ b/vendor/github.com/xenolf/lego/providers/dns/vultr/vultr_test.go @@ -0,0 +1,65 @@ +package vultr + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + liveTest bool + apiKey string + domain string +) + +func init() { + apiKey = os.Getenv("VULTR_API_KEY") + domain = os.Getenv("VULTR_TEST_DOMAIN") + liveTest = len(apiKey) > 0 && len(domain) > 0 +} + +func restoreEnv() { + os.Setenv("VULTR_API_KEY", apiKey) +} + +func TestNewDNSProviderValidEnv(t *testing.T) { + os.Setenv("VULTR_API_KEY", "123") + defer restoreEnv() + _, err := NewDNSProvider() + assert.NoError(t, err) +} + +func TestNewDNSProviderMissingCredErr(t *testing.T) { + os.Setenv("VULTR_API_KEY", "") + defer restoreEnv() + _, err := NewDNSProvider() + assert.EqualError(t, err, "Vultr credentials missing") +} + +func TestLivePresent(t *testing.T) { + if !liveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.Present(domain, "", "123d==") + assert.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !liveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 1) + + provider, err := NewDNSProvider() + assert.NoError(t, err) + + err = provider.CleanUp(domain, "", "123d==") + assert.NoError(t, err) +} -- cgit v1.2.3-1-g7c22