From bba134ce87cc443174d9341b2791719b098772e2 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Sat, 15 Sep 2018 19:07:24 +0200 Subject: [PATCH] Allow to configure TTL, interval and timeout (#634) * feat: add GetOrDefaultXXX methods. * refactor: configuration (alidns). * refactor: configuration (azure). * refactor: configuration (auroradns). * refactor: configuration (bluecat). * refactor: configuration (cloudflare). * refactor: configuration (digitalocean). * refactor: configuration (dnsimple). * refactor: configuration (dnmadeeasy). * refactor: configuration (dnspod). * refactor: configuration (duckdns). * refactor: configuration (dyn). * refactor: configuration (exoscale). * refactor: configuration (fastdns). * refactor: configuration (gandi). * refactor: configuration (gandiv5). * refactor: configuration (gcloud). * refactor: configuration (glesys). * refactor: configuration (godaddy). * refactor: configuration (iij). * refactor: configuration (lightsail). * refactor: configuration (linode). * refactor: configuration (namecheap). * refactor: configuration (namedotcom). * refactor: configuration (netcup). * refactor: configuration (nifcloud). * refactor: configuration (ns1). * refactor: configuration (otc). * refactor: configuration (ovh). * refactor: configuration (pdns). * refactor: configuration (rackspace). * refactor: configuration (rfc2136). * refactor: configuration (route53). * refactor: configuration (sakuracloud). * refactor: configuration (vegadns). * refactor: configuration (vultr). --- Gopkg.lock | 2 + acme/dns_challenge.go | 10 +- platform/config/env/env.go | 34 ++ platform/config/env/env_test.go | 124 +++++- providers/dns/alidns/alidns.go | 102 +++-- providers/dns/alidns/alidns_test.go | 21 +- providers/dns/auroradns/auroradns.go | 114 +++-- providers/dns/auroradns/auroradns_test.go | 28 +- providers/dns/azure/azure.go | 141 +++--- providers/dns/azure/azure_test.go | 30 +- providers/dns/bluecat/bluecat.go | 376 ++++++++-------- providers/dns/bluecat/client.go | 16 + providers/dns/cloudflare/client.go | 212 +++++++++ providers/dns/cloudflare/client_test.go | 188 ++++++++ providers/dns/cloudflare/cloudflare.go | 236 +++------- providers/dns/cloudflare/cloudflare_test.go | 22 +- providers/dns/digitalocean/client.go | 26 ++ providers/dns/digitalocean/digitalocean.go | 254 +++++++---- .../dns/digitalocean/digitalocean_test.go | 28 +- providers/dns/dnsimple/dnsimple.go | 84 +++- providers/dns/dnsimple/dnsimple_test.go | 44 +- providers/dns/dnsmadeeasy/client.go | 168 +++++++ providers/dns/dnsmadeeasy/dnsmadeeasy.go | 227 ++++------ providers/dns/dnspod/dnspod.go | 82 +++- providers/dns/dnspod/dnspod_test.go | 17 +- providers/dns/duckdns/duckdns.go | 65 ++- providers/dns/duckdns/duckdns_test.go | 2 +- providers/dns/dyn/client.go | 35 ++ providers/dns/dyn/dyn.go | 302 ++++++------- providers/dns/exoscale/exoscale.go | 84 +++- providers/dns/exoscale/exoscale_test.go | 32 +- providers/dns/fastdns/fastdns.go | 91 ++-- providers/dns/fastdns/fastdns_test.go | 42 +- providers/dns/gandi/client.go | 94 ++++ providers/dns/gandi/gandi.go | 266 +++++------ providers/dns/gandi/gandi_test.go | 22 +- providers/dns/gandiv5/client.go | 18 + providers/dns/gandiv5/gandiv5.go | 170 +++++--- providers/dns/gandiv5/gandiv5_test.go | 22 +- providers/dns/gcloud/googlecloud.go | 132 +++--- providers/dns/gcloud/googlecloud_test.go | 2 +- providers/dns/glesys/client.go | 24 + providers/dns/glesys/glesys.go | 122 +++--- providers/dns/godaddy/godaddy.go | 90 ++-- providers/dns/iij/iij.go | 98 +++-- providers/dns/iij/iij_test.go | 2 +- providers/dns/lightsail/lightsail.go | 92 ++-- providers/dns/lightsail/lightsail_test.go | 4 +- providers/dns/linode/linode.go | 70 ++- providers/dns/linode/linode_test.go | 13 +- providers/dns/namecheap/client.go | 44 ++ providers/dns/namecheap/namecheap.go | 328 +++++++------- providers/dns/namecheap/namecheap_test.go | 412 +++++++++--------- providers/dns/namedotcom/namedotcom.go | 106 +++-- providers/dns/namedotcom/namedotcom_test.go | 14 +- providers/dns/netcup/client.go | 64 +-- providers/dns/netcup/client_test.go | 19 +- providers/dns/netcup/netcup.go | 106 +++-- providers/dns/nifcloud/client.go | 38 +- providers/dns/nifcloud/client_test.go | 12 +- providers/dns/nifcloud/nifcloud.go | 100 ++++- providers/dns/ns1/ns1.go | 78 +++- providers/dns/ns1/ns1_test.go | 17 +- providers/dns/otc/client.go | 68 +++ providers/dns/otc/otc.go | 362 ++++++++------- providers/dns/otc/otc_test.go | 26 +- providers/dns/ovh/ovh.go | 107 +++-- providers/dns/ovh/ovh_test.go | 10 +- providers/dns/pdns/pdns.go | 119 +++-- providers/dns/pdns/pdns_test.go | 17 +- providers/dns/rackspace/client.go | 47 ++ providers/dns/rackspace/rackspace.go | 172 ++++---- providers/dns/rackspace/rackspace_test.go | 36 +- providers/dns/rfc2136/rfc2136.go | 166 ++++--- providers/dns/rfc2136/rfc2136_test.go | 22 +- providers/dns/route53/route53.go | 36 +- providers/dns/sakuracloud/sakuracloud.go | 83 +++- providers/dns/sakuracloud/sakuracloud_test.go | 28 +- providers/dns/vegadns/vegadns.go | 98 +++-- providers/dns/vegadns/vegadns_test.go | 8 +- providers/dns/vultr/vultr.go | 96 +++- providers/dns/vultr/vultr_test.go | 2 +- 82 files changed, 4800 insertions(+), 2521 deletions(-) create mode 100644 providers/dns/bluecat/client.go create mode 100644 providers/dns/cloudflare/client.go create mode 100644 providers/dns/cloudflare/client_test.go create mode 100644 providers/dns/digitalocean/client.go create mode 100644 providers/dns/dnsmadeeasy/client.go create mode 100644 providers/dns/dyn/client.go create mode 100644 providers/dns/gandi/client.go create mode 100644 providers/dns/gandiv5/client.go create mode 100644 providers/dns/glesys/client.go create mode 100644 providers/dns/namecheap/client.go create mode 100644 providers/dns/otc/client.go create mode 100644 providers/dns/rackspace/client.go diff --git a/Gopkg.lock b/Gopkg.lock index b365a457..07829b27 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -522,6 +522,8 @@ "github.com/OpenDNS/vegadns2client", "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v1", "github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid", + "github.com/aliyun/alibaba-cloud-sdk-go/sdk", + "github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth/credentials", "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests", "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns", "github.com/aws/aws-sdk-go/aws", diff --git a/acme/dns_challenge.go b/acme/dns_challenge.go index 2a43ce37..65427491 100644 --- a/acme/dns_challenge.go +++ b/acme/dns_challenge.go @@ -24,6 +24,14 @@ var ( const defaultResolvConf = "/etc/resolv.conf" +const ( + // DefaultPropagationTimeout default propagation timeout + DefaultPropagationTimeout = 60 * time.Second + + // DefaultPollingInterval default polling interval + DefaultPollingInterval = 2 * time.Second +) + var defaultNameservers = []string{ "google-public-dns-a.google.com:53", "google-public-dns-b.google.com:53", @@ -112,7 +120,7 @@ func (s *dnsChallenge) Solve(chlng challenge, domain string) error { case ChallengeProviderTimeout: timeout, interval = provider.Timeout() default: - timeout, interval = 60*time.Second, 2*time.Second + timeout, interval = DefaultPropagationTimeout, DefaultPollingInterval } err = WaitFor(timeout, interval, func() (bool, error) { diff --git a/platform/config/env/env.go b/platform/config/env/env.go index 267adcda..4da1b4b6 100644 --- a/platform/config/env/env.go +++ b/platform/config/env/env.go @@ -5,6 +5,7 @@ import ( "os" "strconv" "strings" + "time" ) // Get environment variables @@ -37,3 +38,36 @@ func GetOrDefaultInt(envVar string, defaultValue int) int { return v } + +// GetOrDefaultSecond returns the given environment variable value as an time.Duration (second). +// Returns the default if the envvar cannot be coopered to an int, or is not found. +func GetOrDefaultSecond(envVar string, defaultValue time.Duration) time.Duration { + v := GetOrDefaultInt(envVar, -1) + if v < 0 { + return defaultValue + } + + return time.Duration(v) * time.Second +} + +// GetOrDefaultString returns the given environment variable value as a string. +// Returns the default if the envvar cannot be find. +func GetOrDefaultString(envVar string, defaultValue string) string { + v := os.Getenv(envVar) + if len(v) == 0 { + return defaultValue + } + + return v +} + +// GetOrDefaultBool returns the given environment variable value as a boolean. +// Returns the default if the envvar cannot be coopered to a boolean, or is not found. +func GetOrDefaultBool(envVar string, defaultValue bool) bool { + v, err := strconv.ParseBool(os.Getenv(envVar)) + if err != nil { + return defaultValue + } + + return v +} diff --git a/platform/config/env/env_test.go b/platform/config/env/env_test.go index d3c92dc2..c27e6da9 100644 --- a/platform/config/env/env_test.go +++ b/platform/config/env/env_test.go @@ -3,12 +3,13 @@ package env import ( "os" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func Test_GetOrDefaultInt(t *testing.T) { +func TestGetOrDefaultInt(t *testing.T) { testCases := []struct { desc string envValue string @@ -54,3 +55,124 @@ func Test_GetOrDefaultInt(t *testing.T) { }) } } + +func TestGetOrDefaultSecond(t *testing.T) { + testCases := []struct { + desc string + envValue string + defaultValue time.Duration + expected time.Duration + }{ + { + desc: "valid value", + envValue: "100", + defaultValue: 2 * time.Second, + expected: 100 * time.Second, + }, + { + desc: "invalid content, use default value", + envValue: "abc123", + defaultValue: 2 * time.Second, + expected: 2 * time.Second, + }, + { + desc: "invalid content, negative value", + envValue: "-111", + defaultValue: 2 * time.Second, + expected: 2 * time.Second, + }, + { + desc: "float: invalid type, use default value", + envValue: "1.11", + defaultValue: 2 * time.Second, + expected: 2 * time.Second, + }, + } + + var key = "LEGO_ENV_TC" + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer os.Unsetenv(key) + err := os.Setenv(key, test.envValue) + require.NoError(t, err) + + result := GetOrDefaultSecond(key, test.defaultValue) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestGetOrDefaultString(t *testing.T) { + testCases := []struct { + desc string + envValue string + defaultValue string + expected string + }{ + { + desc: "missing env var", + defaultValue: "foo", + expected: "foo", + }, + { + desc: "with env var", + envValue: "bar", + defaultValue: "foo", + expected: "bar", + }, + } + + var key = "LEGO_ENV_TC" + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer os.Unsetenv(key) + err := os.Setenv(key, test.envValue) + require.NoError(t, err) + + actual := GetOrDefaultString(key, test.defaultValue) + assert.Equal(t, test.expected, actual) + }) + } +} + +func TestGetOrDefaultBool(t *testing.T) { + testCases := []struct { + desc string + envValue string + defaultValue bool + expected bool + }{ + { + desc: "missing env var", + defaultValue: true, + expected: true, + }, + { + desc: "with env var", + envValue: "true", + defaultValue: false, + expected: true, + }, + { + desc: "invalid value", + envValue: "foo", + defaultValue: false, + expected: false, + }, + } + + var key = "LEGO_ENV_TC" + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer os.Unsetenv(key) + err := os.Setenv(key, test.envValue) + require.NoError(t, err) + + actual := GetOrDefaultBool(key, test.defaultValue) + assert.Equal(t, test.expected, actual) + }) + } +} diff --git a/providers/dns/alidns/alidns.go b/providers/dns/alidns/alidns.go index 13fd853a..2d3c38b6 100644 --- a/providers/dns/alidns/alidns.go +++ b/providers/dns/alidns/alidns.go @@ -3,10 +3,14 @@ package alidns import ( + "errors" "fmt" "os" "strings" + "time" + "github.com/aliyun/alibaba-cloud-sdk-go/sdk" + "github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth/credentials" "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests" "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns" "github.com/xenolf/lego/acme" @@ -15,8 +19,30 @@ import ( const defaultRegionID = "cn-hangzhou" +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIKey string + SecretKey string + RegionID string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPTimeout time.Duration +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("ALICLOUD_TTL", 600), + PropagationTimeout: env.GetOrDefaultSecond("ALICLOUD_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("ALICLOUD_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPTimeout: env.GetOrDefaultSecond("ALICLOUD_HTTP_TIMEOUT", 10*time.Second), + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface type DNSProvider struct { + config *Config client *alidns.Client } @@ -25,48 +51,74 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("ALICLOUD_ACCESS_KEY", "ALICLOUD_SECRET_KEY") if err != nil { - return nil, fmt.Errorf("AliDNS: %v", err) + return nil, fmt.Errorf("alicloud: %v", err) } - regionID := os.Getenv("ALICLOUD_REGION_ID") + config := NewDefaultConfig() + config.APIKey = values["ALICLOUD_ACCESS_KEY"] + config.SecretKey = values["ALICLOUD_SECRET_KEY"] + config.RegionID = os.Getenv("ALICLOUD_REGION_ID") - return NewDNSProviderCredentials(values["ALICLOUD_ACCESS_KEY"], values["ALICLOUD_SECRET_KEY"], regionID) + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a DNSProvider instance configured for alidns. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for alidns. +// Deprecated func NewDNSProviderCredentials(apiKey, secretKey, regionID string) (*DNSProvider, error) { - if apiKey == "" || secretKey == "" { - return nil, fmt.Errorf("AliDNS: credentials missing") + config := NewDefaultConfig() + config.APIKey = apiKey + config.SecretKey = secretKey + config.RegionID = regionID + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for alidns. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("alicloud: the configuration of the DNS provider is nil") } - if len(regionID) == 0 { - regionID = defaultRegionID + if config.APIKey == "" || config.SecretKey == "" { + return nil, fmt.Errorf("alicloud: credentials missing") } - client, err := alidns.NewClientWithAccessKey(regionID, apiKey, secretKey) + if len(config.RegionID) == 0 { + config.RegionID = defaultRegionID + } + + conf := sdk.NewConfig().WithTimeout(config.HTTPTimeout) + credential := credentials.NewAccessKeyCredential(config.APIKey, config.SecretKey) + + client, err := alidns.NewClientWithOptions(config.RegionID, conf, credential) if err != nil { - return nil, fmt.Errorf("AliDNS: credentials failed: %v", err) + return nil, fmt.Errorf("alicloud: credentials failed: %v", err) } - return &DNSProvider{ - client: client, - }, nil + return &DNSProvider{config: config, client: client}, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfil the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) _, zoneName, err := d.getHostedZone(domain) if err != nil { - return err + return fmt.Errorf("alicloud: %v", err) } - recordAttributes := d.newTxtRecord(zoneName, fqdn, value, ttl) + recordAttributes := d.newTxtRecord(zoneName, fqdn, value) _, err = d.client.AddDomainRecord(recordAttributes) if err != nil { - return fmt.Errorf("AliDNS: API call failed: %v", err) + return fmt.Errorf("alicloud: API call failed: %v", err) } return nil } @@ -77,12 +129,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { records, err := d.findTxtRecords(domain, fqdn) if err != nil { - return err + return fmt.Errorf("alicloud: %v", err) } _, _, err = d.getHostedZone(domain) if err != nil { - return err + return fmt.Errorf("alicloud: %v", err) } for _, rec := range records { @@ -90,7 +142,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { request.RecordId = rec.RecordId _, err = d.client.DeleteDomainRecord(request) if err != nil { - return err + return fmt.Errorf("alicloud: %v", err) } } return nil @@ -100,7 +152,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, string, error) { request := alidns.CreateDescribeDomainsRequest() zones, err := d.client.DescribeDomains(request) if err != nil { - return "", "", fmt.Errorf("AliDNS: API call failed: %v", err) + return "", "", fmt.Errorf("API call failed: %v", err) } authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) @@ -116,18 +168,18 @@ func (d *DNSProvider) getHostedZone(domain string) (string, string, error) { } if hostedZone.DomainId == "" { - return "", "", fmt.Errorf("AliDNS: zone %s not found in AliDNS for domain %s", authZone, domain) + return "", "", fmt.Errorf("zone %s not found in AliDNS for domain %s", authZone, domain) } return fmt.Sprintf("%v", hostedZone.DomainId), hostedZone.DomainName, nil } -func (d *DNSProvider) newTxtRecord(zone, fqdn, value string, ttl int) *alidns.AddDomainRecordRequest { +func (d *DNSProvider) newTxtRecord(zone, fqdn, value string) *alidns.AddDomainRecordRequest { request := alidns.CreateAddDomainRecordRequest() request.Type = "TXT" request.DomainName = zone request.RR = d.extractRecordName(fqdn, zone) request.Value = value - request.TTL = requests.NewInteger(600) + request.TTL = requests.NewInteger(d.config.TTL) return request } @@ -145,7 +197,7 @@ func (d *DNSProvider) findTxtRecords(domain, fqdn string) ([]alidns.Record, erro result, err := d.client.DescribeDomainRecords(request) if err != nil { - return records, fmt.Errorf("AliDNS: API call has failed: %v", err) + return records, fmt.Errorf("API call has failed: %v", err) } recordName := d.extractRecordName(fqdn, zoneName) diff --git a/providers/dns/alidns/alidns_test.go b/providers/dns/alidns/alidns_test.go index 2d201310..a081f90f 100644 --- a/providers/dns/alidns/alidns_test.go +++ b/providers/dns/alidns/alidns_test.go @@ -35,7 +35,11 @@ func TestNewDNSProviderValid(t *testing.T) { os.Setenv("ALICLOUD_ACCESS_KEY", "") os.Setenv("ALICLOUD_SECRET_KEY", "") - _, err := NewDNSProviderCredentials("123", "123", "") + config := NewDefaultConfig() + config.APIKey = "123" + config.SecretKey = "123" + + _, err := NewDNSProviderConfig(config) assert.NoError(t, err) } @@ -54,7 +58,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { os.Setenv("ALICLOUD_SECRET_KEY", "") _, err := NewDNSProvider() - assert.EqualError(t, err, "AliDNS: some credentials information are missing: ALICLOUD_ACCESS_KEY,ALICLOUD_SECRET_KEY") + assert.EqualError(t, err, "alicloud: some credentials information are missing: ALICLOUD_ACCESS_KEY,ALICLOUD_SECRET_KEY") } func TestCloudXNSPresent(t *testing.T) { @@ -62,7 +66,11 @@ func TestCloudXNSPresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderCredentials(alidnsAPIKey, alidnsSecretKey, "") + config := NewDefaultConfig() + config.APIKey = alidnsAPIKey + config.SecretKey = alidnsSecretKey + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.Present(alidnsDomain, "", "123d==") @@ -75,7 +83,12 @@ func TestLivednspodCleanUp(t *testing.T) { } time.Sleep(time.Second * 1) - provider, err := NewDNSProviderCredentials(alidnsAPIKey, alidnsSecretKey, "") + + config := NewDefaultConfig() + config.APIKey = alidnsAPIKey + config.SecretKey = alidnsSecretKey + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.CleanUp(alidnsDomain, "", "123d==") assert.NoError(t, err) diff --git a/providers/dns/auroradns/auroradns.go b/providers/dns/auroradns/auroradns.go index 5c821507..5ddbc7af 100644 --- a/providers/dns/auroradns/auroradns.go +++ b/providers/dns/auroradns/auroradns.go @@ -1,9 +1,11 @@ package auroradns import ( + "errors" "fmt" "os" "sync" + "time" "github.com/edeckers/auroradnsclient" "github.com/edeckers/auroradnsclient/records" @@ -12,68 +14,97 @@ import ( "github.com/xenolf/lego/platform/config/env" ) +const defaultBaseURL = "https://api.auroradns.eu" + +// Config is used to configure the creation of the DNSProvider +type Config struct { + BaseURL string + UserID string + Key string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("AURORA_TTL", 300), + PropagationTimeout: env.GetOrDefaultSecond("AURORA_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("AURORA_POLLING_INTERVAL", acme.DefaultPollingInterval), + } +} + // DNSProvider describes a provider for AuroraDNS type DNSProvider struct { recordIDs map[string]string recordIDsMu sync.Mutex + config *Config client *auroradnsclient.AuroraDNSClient } // NewDNSProvider returns a DNSProvider instance configured for AuroraDNS. -// Credentials must be passed in the environment variables: AURORA_USER_ID -// and AURORA_KEY. +// Credentials must be passed in the environment variables: +// AURORA_USER_ID and AURORA_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("AURORA_USER_ID", "AURORA_KEY") if err != nil { - return nil, fmt.Errorf("AuroraDNS: %v", err) + return nil, fmt.Errorf("aurora: %v", err) } - endpoint := os.Getenv("AURORA_ENDPOINT") + config := NewDefaultConfig() + config.BaseURL = os.Getenv("AURORA_ENDPOINT") + config.UserID = values["AURORA_USER_ID"] + config.Key = values["AURORA_KEY"] - return NewDNSProviderCredentials(endpoint, values["AURORA_USER_ID"], values["AURORA_KEY"]) + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for AuroraDNS. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for AuroraDNS. +// Deprecated func NewDNSProviderCredentials(baseURL string, userID string, key string) (*DNSProvider, error) { - if baseURL == "" { - baseURL = "https://api.auroradns.eu" + config := NewDefaultConfig() + config.BaseURL = baseURL + config.UserID = userID + config.Key = key + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for AuroraDNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("aurora: the configuration of the DNS provider is nil") } - client, err := auroradnsclient.NewAuroraDNSClient(baseURL, userID, key) + if config.UserID == "" || config.Key == "" { + return nil, errors.New("aurora: some credentials information are missing") + } + + if config.BaseURL == "" { + config.BaseURL = defaultBaseURL + } + + client, err := auroradnsclient.NewAuroraDNSClient(config.BaseURL, config.UserID, config.Key) if err != nil { - return nil, err + return nil, fmt.Errorf("aurora: %v", err) } return &DNSProvider{ + config: config, client: client, recordIDs: make(map[string]string), }, nil } -func (d *DNSProvider) getZoneInformationByName(name string) (zones.ZoneRecord, error) { - zs, err := d.client.GetZones() - - if err != nil { - return zones.ZoneRecord{}, err - } - - for _, element := range zs { - if element.Name == name { - return element, nil - } - } - - return zones.ZoneRecord{}, fmt.Errorf("could not find Zone record") -} - // Present creates a record with a secret func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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) + return fmt.Errorf("aurora: could not determine zone for domain: '%s'. %s", domain, err) } // 1. Aurora will happily create the TXT record when it is provided a fqdn, @@ -89,7 +120,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { zoneRecord, err := d.getZoneInformationByName(authZone) if err != nil { - return fmt.Errorf("could not create record: %v", err) + return fmt.Errorf("aurora: could not create record: %v", err) } reqData := @@ -97,12 +128,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { RecordType: "TXT", Name: subdomain, Content: value, - TTL: 300, + TTL: d.config.TTL, } respData, err := d.client.CreateRecord(zoneRecord.ID, reqData) if err != nil { - return fmt.Errorf("could not create record: %v", err) + return fmt.Errorf("aurora: could not create record: %v", err) } d.recordIDsMu.Lock() @@ -147,3 +178,24 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) getZoneInformationByName(name string) (zones.ZoneRecord, error) { + zs, err := d.client.GetZones() + if err != nil { + return zones.ZoneRecord{}, err + } + + for _, element := range zs { + if element.Name == name { + return element, nil + } + } + + return zones.ZoneRecord{}, fmt.Errorf("could not find Zone record") +} diff --git a/providers/dns/auroradns/auroradns_test.go b/providers/dns/auroradns/auroradns_test.go index 2c41ccbd..81ce3740 100644 --- a/providers/dns/auroradns/auroradns_test.go +++ b/providers/dns/auroradns/auroradns_test.go @@ -48,11 +48,16 @@ func TestAuroraDNSPresent(t *testing.T) { defer mock.Close() - auroraProvider, err := NewDNSProviderCredentials(mock.URL, fakeAuroraDNSUserID, fakeAuroraDNSKey) - require.NoError(t, err) - require.NotNil(t, auroraProvider) + config := NewDefaultConfig() + config.UserID = fakeAuroraDNSUserID + config.Key = fakeAuroraDNSKey + config.BaseURL = mock.URL - err = auroraProvider.Present("example.com", "", "foobar") + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) + require.NotNil(t, provider) + + err = provider.Present("example.com", "", "foobar") require.NoError(t, err, "fail to create TXT record") assert.True(t, requestReceived, "Expected request to be received by mock backend, but it wasn't") @@ -93,14 +98,19 @@ func TestAuroraDNSCleanUp(t *testing.T) { })) defer mock.Close() - auroraProvider, err := NewDNSProviderCredentials(mock.URL, fakeAuroraDNSUserID, fakeAuroraDNSKey) - require.NoError(t, err) - require.NotNil(t, auroraProvider) + config := NewDefaultConfig() + config.UserID = fakeAuroraDNSUserID + config.Key = fakeAuroraDNSKey + config.BaseURL = mock.URL - err = auroraProvider.Present("example.com", "", "foobar") + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) + require.NotNil(t, provider) + + err = provider.Present("example.com", "", "foobar") require.NoError(t, err, "fail to create TXT record") - err = auroraProvider.CleanUp("example.com", "", "foobar") + err = provider.CleanUp("example.com", "", "foobar") require.NoError(t, err, "fail to remove TXT record") assert.True(t, requestReceived, "Expected request to be received by mock backend, but it wasn't") diff --git a/providers/dns/azure/azure.go b/providers/dns/azure/azure.go index bbf2c7fb..7f156993 100644 --- a/providers/dns/azure/azure.go +++ b/providers/dns/azure/azure.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "net/http" "strings" "time" @@ -19,14 +20,31 @@ import ( "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + ClientID string + ClientSecret string + SubscriptionID string + TenantID string + ResourceGroup string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("AZURE_TTL", 60), + PropagationTimeout: env.GetOrDefaultSecond("AZURE_PROPAGATION_TIMEOUT", 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond("AZURE_POLLING_INTERVAL", 2*time.Second), + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface type DNSProvider struct { - clientID string - clientSecret string - subscriptionID string - tenantID string - resourceGroup string - context context.Context + config *Config } // NewDNSProvider returns a DNSProvider instance configured for azure. @@ -35,54 +53,66 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET", "AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID", "AZURE_RESOURCE_GROUP") if err != nil { - return nil, fmt.Errorf("Azure: %v", err) + return nil, fmt.Errorf("azure: %v", err) } - return NewDNSProviderCredentials( - values["AZURE_CLIENT_ID"], - values["AZURE_CLIENT_SECRET"], - values["AZURE_SUBSCRIPTION_ID"], - values["AZURE_TENANT_ID"], - values["AZURE_RESOURCE_GROUP"], - ) + config := NewDefaultConfig() + config.ClientID = values["AZURE_CLIENT_ID"] + config.ClientSecret = values["AZURE_CLIENT_SECRET"] + config.SubscriptionID = values["AZURE_SUBSCRIPTION_ID"] + config.TenantID = values["AZURE_TENANT_ID"] + config.ResourceGroup = values["AZURE_RESOURCE_GROUP"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for azure. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for azure. +// Deprecated func NewDNSProviderCredentials(clientID, clientSecret, subscriptionID, tenantID, resourceGroup string) (*DNSProvider, error) { - if clientID == "" || clientSecret == "" || subscriptionID == "" || tenantID == "" || resourceGroup == "" { - return nil, errors.New("Azure: some credentials information are missing") + config := NewDefaultConfig() + config.ClientID = clientID + config.ClientSecret = clientSecret + config.SubscriptionID = subscriptionID + config.TenantID = tenantID + config.ResourceGroup = resourceGroup + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Azure. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("azure: the configuration of the DNS provider is nil") } - return &DNSProvider{ - clientID: clientID, - clientSecret: clientSecret, - subscriptionID: subscriptionID, - tenantID: tenantID, - resourceGroup: resourceGroup, - // TODO: A timeout can be added here for cancellation purposes. - context: context.Background(), - }, nil + if config.ClientID == "" || config.ClientSecret == "" || config.SubscriptionID == "" || config.TenantID == "" || config.ResourceGroup == "" { + return nil, errors.New("azure: some credentials information are missing") + } + + return &DNSProvider{config: config}, nil } // Timeout returns the timeout and interval to use when checking for DNS // propagation. Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return 120 * time.Second, 2 * time.Second + return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfil the dns-01 challenge func (d *DNSProvider) Present(domain, token, keyAuth string) error { + ctx := context.Background() fqdn, value, _ := acme.DNS01Record(domain, keyAuth) - zone, err := d.getHostedZoneID(fqdn) + + zone, err := d.getHostedZoneID(ctx, fqdn) if err != nil { - return err + return fmt.Errorf("azure: %v", err) } - rsc := dns.NewRecordSetsClient(d.subscriptionID) - spt, err := d.newServicePrincipalTokenFromCredentials(azure.PublicCloud.ResourceManagerEndpoint) + rsc := dns.NewRecordSetsClient(d.config.SubscriptionID) + spt, err := d.newServicePrincipalToken(azure.PublicCloud.ResourceManagerEndpoint) if err != nil { - return err + return fmt.Errorf("azure: %v", err) } rsc.Authorizer = autorest.NewBearerAuthorizer(spt) @@ -91,59 +121,55 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { rec := dns.RecordSet{ Name: &relative, RecordSetProperties: &dns.RecordSetProperties{ - TTL: to.Int64Ptr(60), + TTL: to.Int64Ptr(int64(d.config.TTL)), TxtRecords: &[]dns.TxtRecord{{Value: &[]string{value}}}, }, } - _, err = rsc.CreateOrUpdate(d.context, d.resourceGroup, zone, relative, dns.TXT, rec, "", "") - return err -} - -// Returns the relative record to the domain -func toRelativeRecord(domain, zone string) string { - return acme.UnFqdn(strings.TrimSuffix(domain, zone)) + _, err = rsc.CreateOrUpdate(ctx, d.config.ResourceGroup, zone, relative, dns.TXT, rec, "", "") + return fmt.Errorf("azure: %v", err) } // CleanUp removes the TXT record matching the specified parameters func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + ctx := context.Background() fqdn, _, _ := acme.DNS01Record(domain, keyAuth) - zone, err := d.getHostedZoneID(fqdn) + zone, err := d.getHostedZoneID(ctx, fqdn) if err != nil { - return err + return fmt.Errorf("azure: %v", err) } relative := toRelativeRecord(fqdn, acme.ToFqdn(zone)) - rsc := dns.NewRecordSetsClient(d.subscriptionID) - spt, err := d.newServicePrincipalTokenFromCredentials(azure.PublicCloud.ResourceManagerEndpoint) + rsc := dns.NewRecordSetsClient(d.config.SubscriptionID) + spt, err := d.newServicePrincipalToken(azure.PublicCloud.ResourceManagerEndpoint) if err != nil { - return err + return fmt.Errorf("azure: %v", err) } rsc.Authorizer = autorest.NewBearerAuthorizer(spt) - _, err = rsc.Delete(d.context, d.resourceGroup, zone, relative, dns.TXT, "") - return err + _, err = rsc.Delete(ctx, d.config.ResourceGroup, zone, relative, dns.TXT, "") + return fmt.Errorf("azure: %v", err) } // Checks that azure has a zone for this domain name. -func (d *DNSProvider) getHostedZoneID(fqdn string) (string, error) { +func (d *DNSProvider) getHostedZoneID(ctx context.Context, fqdn string) (string, error) { authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) if err != nil { return "", err } // Now we want to to Azure and get the zone. - spt, err := d.newServicePrincipalTokenFromCredentials(azure.PublicCloud.ResourceManagerEndpoint) + spt, err := d.newServicePrincipalToken(azure.PublicCloud.ResourceManagerEndpoint) if err != nil { return "", err } - dc := dns.NewZonesClient(d.subscriptionID) + dc := dns.NewZonesClient(d.config.SubscriptionID) dc.Authorizer = autorest.NewBearerAuthorizer(spt) - zone, err := dc.Get(d.context, d.resourceGroup, acme.UnFqdn(authZone)) + zone, err := dc.Get(ctx, d.config.ResourceGroup, acme.UnFqdn(authZone)) if err != nil { return "", err } @@ -154,10 +180,15 @@ func (d *DNSProvider) getHostedZoneID(fqdn string) (string, error) { // NewServicePrincipalTokenFromCredentials creates a new ServicePrincipalToken using values of the // passed credentials map. -func (d *DNSProvider) newServicePrincipalTokenFromCredentials(scope string) (*adal.ServicePrincipalToken, error) { - oauthConfig, err := adal.NewOAuthConfig(azure.PublicCloud.ActiveDirectoryEndpoint, d.tenantID) +func (d *DNSProvider) newServicePrincipalToken(scope string) (*adal.ServicePrincipalToken, error) { + oauthConfig, err := adal.NewOAuthConfig(azure.PublicCloud.ActiveDirectoryEndpoint, d.config.TenantID) if err != nil { return nil, err } - return adal.NewServicePrincipalToken(*oauthConfig, d.clientID, d.clientSecret, scope) + return adal.NewServicePrincipalToken(*oauthConfig, d.config.ClientID, d.config.ClientSecret, scope) +} + +// Returns the relative record to the domain +func toRelativeRecord(domain, zone string) string { + return acme.UnFqdn(strings.TrimSuffix(domain, zone)) } diff --git a/providers/dns/azure/azure_test.go b/providers/dns/azure/azure_test.go index 212d8566..85dc14c9 100644 --- a/providers/dns/azure/azure_test.go +++ b/providers/dns/azure/azure_test.go @@ -43,7 +43,14 @@ func TestNewDNSProviderValid(t *testing.T) { defer restoreEnv() os.Setenv("AZURE_CLIENT_ID", "") - _, err := NewDNSProviderCredentials(azureClientID, azureClientSecret, azureSubscriptionID, azureTenantID, azureResourceGroup) + config := NewDefaultConfig() + config.ClientID = azureClientID + config.ClientSecret = azureClientSecret + config.SubscriptionID = azureSubscriptionID + config.TenantID = azureTenantID + config.ResourceGroup = azureResourceGroup + + _, err := NewDNSProviderConfig(config) assert.NoError(t, err) } @@ -64,7 +71,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { os.Setenv("AZURE_SUBSCRIPTION_ID", "") _, err := NewDNSProvider() - assert.EqualError(t, err, "Azure: some credentials information are missing: AZURE_CLIENT_ID,AZURE_CLIENT_SECRET,AZURE_SUBSCRIPTION_ID,AZURE_TENANT_ID,AZURE_RESOURCE_GROUP") + assert.EqualError(t, err, "azure: some credentials information are missing: AZURE_CLIENT_ID,AZURE_CLIENT_SECRET,AZURE_SUBSCRIPTION_ID,AZURE_TENANT_ID,AZURE_RESOURCE_GROUP") } func TestLiveAzurePresent(t *testing.T) { @@ -72,7 +79,14 @@ func TestLiveAzurePresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderCredentials(azureClientID, azureClientSecret, azureSubscriptionID, azureTenantID, azureResourceGroup) + config := NewDefaultConfig() + config.ClientID = azureClientID + config.ClientSecret = azureClientSecret + config.SubscriptionID = azureSubscriptionID + config.TenantID = azureTenantID + config.ResourceGroup = azureResourceGroup + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.Present(azureDomain, "", "123d==") @@ -84,7 +98,15 @@ func TestLiveAzureCleanUp(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderCredentials(azureClientID, azureClientSecret, azureSubscriptionID, azureTenantID, azureResourceGroup) + config := NewDefaultConfig() + config.ClientID = azureClientID + config.ClientSecret = azureClientSecret + config.SubscriptionID = azureSubscriptionID + config.TenantID = azureTenantID + config.ResourceGroup = azureResourceGroup + + provider, err := NewDNSProviderConfig(config) + time.Sleep(time.Second * 1) assert.NoError(t, err) diff --git a/providers/dns/bluecat/bluecat.go b/providers/dns/bluecat/bluecat.go index d88d73b1..cc4369ba 100644 --- a/providers/dns/bluecat/bluecat.go +++ b/providers/dns/bluecat/bluecat.go @@ -5,6 +5,7 @@ package bluecat import ( "bytes" "encoding/json" + "errors" "fmt" "io/ioutil" "net/http" @@ -17,91 +18,221 @@ import ( "github.com/xenolf/lego/platform/config/env" ) -const bluecatURLTemplate = "%s/Services/REST/v1" const configType = "Configuration" const viewType = "View" const txtType = "TXTRecord" const zoneType = "Zone" -type entityResponse struct { - ID uint `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Properties string `json:"properties"` +// Config is used to configure the creation of the DNSProvider +type Config struct { + BaseURL string + UserName string + Password string + ConfigName string + DNSView string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("BLUECAT_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("BLUECAT_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("BLUECAT_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("BLUECAT_HTTP_TIMEOUT", 30*time.Second), + }, + } } // DNSProvider is an implementation of the acme.ChallengeProvider interface that uses // Bluecat's Address Manager REST API to manage TXT records for a domain. type DNSProvider struct { - baseURL string - userName string - password string - configName string - dnsView string - token string - client *http.Client + config *Config + token string } // NewDNSProvider returns a DNSProvider instance configured for Bluecat DNS. -// Credentials must be passed in the environment variables: BLUECAT_SERVER_URL, -// BLUECAT_USER_NAME and BLUECAT_PASSWORD. BLUECAT_SERVER_URL should have the -// scheme, hostname, and port (if required) of the authoritative Bluecat BAM -// server. The REST endpoint will be appended. In addition, the Configuration name -// and external DNS View Name must be passed in BLUECAT_CONFIG_NAME and -// BLUECAT_DNS_VIEW +// Credentials must be passed in the environment variables: BLUECAT_SERVER_URL, BLUECAT_USER_NAME and BLUECAT_PASSWORD. +// BLUECAT_SERVER_URL should have the scheme, hostname, and port (if required) of the authoritative Bluecat BAM server. +// The REST endpoint will be appended. In addition, the Configuration name +// and external DNS View Name must be passed in BLUECAT_CONFIG_NAME and BLUECAT_DNS_VIEW func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("BLUECAT_SERVER_URL", "BLUECAT_USER_NAME", "BLUECAT_CONFIG_NAME", "BLUECAT_CONFIG_NAME", "BLUECAT_DNS_VIEW") if err != nil { - return nil, fmt.Errorf("BlueCat: %v", err) + return nil, fmt.Errorf("bluecat: %v", err) } - httpClient := &http.Client{Timeout: 30 * time.Second} + config := NewDefaultConfig() + config.BaseURL = values["BLUECAT_SERVER_URL"] + config.UserName = values["BLUECAT_USER_NAME"] + config.Password = values["BLUECAT_PASSWORD"] + config.ConfigName = values["BLUECAT_CONFIG_NAME"] + config.DNSView = values["BLUECAT_DNS_VIEW"] - return NewDNSProviderCredentials( - values["BLUECAT_SERVER_URL"], - values["BLUECAT_USER_NAME"], - values["BLUECAT_PASSWORD"], - values["BLUECAT_CONFIG_NAME"], - values["BLUECAT_DNS_VIEW"], - httpClient, - ) + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for Bluecat DNS. -func NewDNSProviderCredentials(server, userName, password, configName, dnsView string, httpClient *http.Client) (*DNSProvider, error) { - if server == "" || userName == "" || password == "" || configName == "" || dnsView == "" { - return nil, fmt.Errorf("Bluecat credentials missing") - } +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for Bluecat DNS. +// Deprecated +func NewDNSProviderCredentials(baseURL, userName, password, configName, dnsView string, httpClient *http.Client) (*DNSProvider, error) { + config := NewDefaultConfig() + config.BaseURL = baseURL + config.UserName = userName + config.Password = password + config.ConfigName = configName + config.DNSView = dnsView - client := http.DefaultClient if httpClient != nil { - client = httpClient + config.HTTPClient = httpClient } - return &DNSProvider{ - baseURL: fmt.Sprintf(bluecatURLTemplate, server), - userName: userName, - password: password, - configName: configName, - dnsView: dnsView, - client: client, - }, nil + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Bluecat DNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("bluecat: the configuration of the DNS provider is nil") + } + + if config.BaseURL == "" || config.UserName == "" || config.Password == "" || config.ConfigName == "" || config.DNSView == "" { + return nil, fmt.Errorf("bluecat: credentials missing") + } + + return &DNSProvider{config: config}, nil +} + +// Present creates a TXT record using the specified parameters +// This will *not* create a subzone to contain the TXT record, +// so make sure the FQDN specified is within an extant zone. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + err := d.login() + if err != nil { + return err + } + + viewID, err := d.lookupViewID(d.config.DNSView) + if err != nil { + return err + } + + parentZoneID, name, err := d.lookupParentZoneID(viewID, fqdn) + if err != nil { + return err + } + + queryArgs := map[string]string{ + "parentId": strconv.FormatUint(uint64(parentZoneID), 10), + } + + body := bluecatEntity{ + Name: name, + Type: "TXTRecord", + Properties: fmt.Sprintf("ttl=%d|absoluteName=%s|txt=%s|", d.config.TTL, fqdn, value), + } + + resp, err := d.sendRequest(http.MethodPost, "addEntity", body, queryArgs) + if err != nil { + return err + } + defer resp.Body.Close() + + addTxtBytes, _ := ioutil.ReadAll(resp.Body) + addTxtResp := string(addTxtBytes) + // addEntity responds only with body text containing the ID of the created record + _, err = strconv.ParseUint(addTxtResp, 10, 64) + if err != nil { + return fmt.Errorf("bluecat: addEntity request failed: %s", addTxtResp) + } + + err = d.deploy(parentZoneID) + if err != nil { + return err + } + + return d.logout() +} + +// CleanUp removes the TXT record matching the specified parameters +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := acme.DNS01Record(domain, keyAuth) + + err := d.login() + if err != nil { + return err + } + + viewID, err := d.lookupViewID(d.config.DNSView) + if err != nil { + return err + } + + parentID, name, err := d.lookupParentZoneID(viewID, fqdn) + if err != nil { + return err + } + + queryArgs := map[string]string{ + "parentId": strconv.FormatUint(uint64(parentID), 10), + "name": name, + "type": txtType, + } + + resp, err := d.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs) + if err != nil { + return err + } + defer resp.Body.Close() + + var txtRec entityResponse + err = json.NewDecoder(resp.Body).Decode(&txtRec) + if err != nil { + return fmt.Errorf("bluecat: %v", err) + } + queryArgs = map[string]string{ + "objectId": strconv.FormatUint(uint64(txtRec.ID), 10), + } + + resp, err = d.sendRequest(http.MethodDelete, http.MethodDelete, nil, queryArgs) + if err != nil { + return err + } + defer resp.Body.Close() + + err = d.deploy(parentID) + if err != nil { + return err + } + + return d.logout() +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } // Send a REST request, using query parameters specified. The Authorization // header will be set if we have an active auth token func (d *DNSProvider) sendRequest(method, resource string, payload interface{}, queryArgs map[string]string) (*http.Response, error) { - url := fmt.Sprintf("%s/%s", d.baseURL, resource) + url := fmt.Sprintf("%s/Services/REST/v1/%s", d.config.BaseURL, resource) body, err := json.Marshal(payload) if err != nil { - return nil, err + return nil, fmt.Errorf("bluecat: %v", err) } req, err := http.NewRequest(method, url, bytes.NewReader(body)) if err != nil { - return nil, err + return nil, fmt.Errorf("bluecat: %v", err) } req.Header.Set("Content-Type", "application/json") if len(d.token) > 0 { @@ -114,15 +245,15 @@ func (d *DNSProvider) sendRequest(method, resource string, payload interface{}, q.Add(argName, argVal) } req.URL.RawQuery = q.Encode() - resp, err := d.client.Do(req) + resp, err := d.config.HTTPClient.Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("bluecat: %v", err) } if resp.StatusCode >= 400 { errBytes, _ := ioutil.ReadAll(resp.Body) errResp := string(errBytes) - return nil, fmt.Errorf("Bluecat API request failed with HTTP status code %d\n Full message: %s", + return nil, fmt.Errorf("bluecat: request failed with HTTP status code %d\n Full message: %s", resp.StatusCode, errResp) } @@ -133,8 +264,8 @@ func (d *DNSProvider) sendRequest(method, resource string, payload interface{}, // password and receives a token to be used in for subsequent requests. func (d *DNSProvider) login() error { queryArgs := map[string]string{ - "username": d.userName, - "password": d.password, + "username": d.config.UserName, + "password": d.config.Password, } resp, err := d.sendRequest(http.MethodGet, "login", nil, queryArgs) @@ -145,18 +276,16 @@ func (d *DNSProvider) login() error { authBytes, err := ioutil.ReadAll(resp.Body) if err != nil { - return err + return fmt.Errorf("bluecat: %v", err) } authResp := string(authBytes) if strings.Contains(authResp, "Authentication Error") { msg := strings.Trim(authResp, "\"") - return fmt.Errorf("Bluecat API request failed: %s", msg) + return fmt.Errorf("bluecat: request failed: %s", msg) } // Upon success, API responds with "Session Token-> BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM= <- for User : username" - re := regexp.MustCompile("BAMAuthToken: [^ ]+") - token := re.FindString(authResp) - d.token = token + d.token = regexp.MustCompile("BAMAuthToken: [^ ]+").FindString(authResp) return nil } @@ -174,7 +303,7 @@ func (d *DNSProvider) logout() error { defer resp.Body.Close() if resp.StatusCode != 200 { - return fmt.Errorf("Bluecat API request failed to delete session with HTTP status code %d", resp.StatusCode) + return fmt.Errorf("bluecat: request failed to delete session with HTTP status code %d", resp.StatusCode) } authBytes, err := ioutil.ReadAll(resp.Body) @@ -185,7 +314,7 @@ func (d *DNSProvider) logout() error { if !strings.Contains(authResp, "successfully") { msg := strings.Trim(authResp, "\"") - return fmt.Errorf("Bluecat API request failed to delete session: %s", msg) + return fmt.Errorf("bluecat: request failed to delete session: %s", msg) } d.token = "" @@ -197,7 +326,7 @@ func (d *DNSProvider) logout() error { func (d *DNSProvider) lookupConfID() (uint, error) { queryArgs := map[string]string{ "parentId": strconv.Itoa(0), - "name": d.configName, + "name": d.config.ConfigName, "type": configType, } @@ -210,7 +339,7 @@ func (d *DNSProvider) lookupConfID() (uint, error) { var conf entityResponse err = json.NewDecoder(resp.Body).Decode(&conf) if err != nil { - return 0, err + return 0, fmt.Errorf("bluecat: %v", err) } return conf.ID, nil } @@ -224,7 +353,7 @@ func (d *DNSProvider) lookupViewID(viewName string) (uint, error) { queryArgs := map[string]string{ "parentId": strconv.FormatUint(uint64(confID), 10), - "name": d.dnsView, + "name": d.config.DNSView, "type": viewType, } @@ -237,7 +366,7 @@ func (d *DNSProvider) lookupViewID(viewName string) (uint, error) { var view entityResponse err = json.NewDecoder(resp.Body).Decode(&view) if err != nil { - return 0, err + return 0, fmt.Errorf("bluecat: %v", err) } return view.ID, nil @@ -280,7 +409,7 @@ func (d *DNSProvider) getZone(parentID uint, name string) (uint, error) { resp, err := d.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs) // Return an empty zone if the named zone doesn't exist if resp != nil && resp.StatusCode == 404 { - return 0, fmt.Errorf("Bluecat API could not find zone named %s", name) + return 0, fmt.Errorf("bluecat: could not find zone named %s", name) } if err != nil { return 0, err @@ -290,65 +419,12 @@ func (d *DNSProvider) getZone(parentID uint, name string) (uint, error) { var zone entityResponse err = json.NewDecoder(resp.Body).Decode(&zone) if err != nil { - return 0, err + return 0, fmt.Errorf("bluecat: %v", err) } return zone.ID, nil } -// Present creates a TXT record using the specified parameters -// This will *not* create a subzone to contain the TXT record, -// so make sure the FQDN specified is within an extant zone. -func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) - - err := d.login() - if err != nil { - return err - } - - viewID, err := d.lookupViewID(d.dnsView) - if err != nil { - return err - } - - parentZoneID, name, err := d.lookupParentZoneID(viewID, fqdn) - if err != nil { - return err - } - - queryArgs := map[string]string{ - "parentId": strconv.FormatUint(uint64(parentZoneID), 10), - } - - body := bluecatEntity{ - Name: name, - Type: "TXTRecord", - Properties: fmt.Sprintf("ttl=%d|absoluteName=%s|txt=%s|", ttl, fqdn, value), - } - - resp, err := d.sendRequest(http.MethodPost, "addEntity", body, queryArgs) - if err != nil { - return err - } - defer resp.Body.Close() - - addTxtBytes, _ := ioutil.ReadAll(resp.Body) - addTxtResp := string(addTxtBytes) - // addEntity responds only with body text containing the ID of the created record - _, err = strconv.ParseUint(addTxtResp, 10, 64) - if err != nil { - return fmt.Errorf("Bluecat API addEntity request failed: %s", addTxtResp) - } - - err = d.deploy(parentZoneID) - if err != nil { - return err - } - - return d.logout() -} - // Deploy the DNS config for the specified entity to the authoritative servers func (d *DNSProvider) deploy(entityID uint) error { queryArgs := map[string]string{ @@ -363,65 +439,3 @@ func (d *DNSProvider) deploy(entityID uint) error { 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) - - err := d.login() - if err != nil { - return err - } - - viewID, err := d.lookupViewID(d.dnsView) - if err != nil { - return err - } - - parentID, name, err := d.lookupParentZoneID(viewID, fqdn) - if err != nil { - return err - } - - queryArgs := map[string]string{ - "parentId": strconv.FormatUint(uint64(parentID), 10), - "name": name, - "type": txtType, - } - - resp, err := d.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs) - if err != nil { - return err - } - defer resp.Body.Close() - - var txtRec entityResponse - err = json.NewDecoder(resp.Body).Decode(&txtRec) - if err != nil { - return err - } - queryArgs = map[string]string{ - "objectId": strconv.FormatUint(uint64(txtRec.ID), 10), - } - - resp, err = d.sendRequest(http.MethodDelete, http.MethodDelete, nil, queryArgs) - if err != nil { - return err - } - defer resp.Body.Close() - - err = d.deploy(parentID) - if err != nil { - return err - } - - return d.logout() -} - -// JSON body for Bluecat entity requests and responses -type bluecatEntity struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - Type string `json:"type"` - Properties string `json:"properties"` -} diff --git a/providers/dns/bluecat/client.go b/providers/dns/bluecat/client.go new file mode 100644 index 00000000..55deeed4 --- /dev/null +++ b/providers/dns/bluecat/client.go @@ -0,0 +1,16 @@ +package bluecat + +// JSON body for Bluecat entity requests and responses +type bluecatEntity struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Properties string `json:"properties"` +} + +type entityResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Properties string `json:"properties"` +} diff --git a/providers/dns/cloudflare/client.go b/providers/dns/cloudflare/client.go new file mode 100644 index 00000000..24c4ae0f --- /dev/null +++ b/providers/dns/cloudflare/client.go @@ -0,0 +1,212 @@ +package cloudflare + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + + "github.com/xenolf/lego/acme" +) + +// defaultBaseURL represents the API endpoint to call. +const defaultBaseURL = "https://api.cloudflare.com/client/v4" + +// 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"` +} + +// TxtRecord represents a Cloudflare DNS record +type TxtRecord 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"` +} + +// HostedZone represents a Cloudflare DNS zone +type HostedZone struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Client Cloudflare API client +type Client struct { + authEmail string + authKey string + BaseURL string + HTTPClient *http.Client +} + +// NewClient create a Cloudflare API client +func NewClient(authEmail string, authKey string) (*Client, error) { + if authEmail == "" { + return nil, errors.New("cloudflare: some credentials information are missing: email") + } + + if authKey == "" { + return nil, errors.New("cloudflare: some credentials information are missing: key") + } + + return &Client{ + authEmail: authEmail, + authKey: authKey, + BaseURL: defaultBaseURL, + HTTPClient: http.DefaultClient, + }, nil +} + +// GetHostedZoneID get hosted zone +func (c *Client) GetHostedZoneID(fqdn string) (string, error) { + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return "", err + } + + result, err := c.doRequest(http.MethodGet, "/zones?name="+acme.UnFqdn(authZone), nil) + if err != nil { + return "", err + } + + var hostedZone []HostedZone + err = json.Unmarshal(result, &hostedZone) + if err != nil { + return "", fmt.Errorf("cloudflare: HostedZone unmarshaling error: %v", err) + } + + count := len(hostedZone) + if count == 0 { + return "", fmt.Errorf("cloudflare: zone %s not found for domain %s", authZone, fqdn) + } else if count > 1 { + return "", fmt.Errorf("cloudflare: zone %s cannot be find for domain %s: too many hostedZone: %v", authZone, fqdn, hostedZone) + } + + return hostedZone[0].ID, nil +} + +// FindTxtRecord Find a TXT record +func (c *Client) FindTxtRecord(zoneID, fqdn string) (*TxtRecord, error) { + result, err := c.doRequest( + http.MethodGet, + 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 []TxtRecord + err = json.Unmarshal(result, &records) + if err != nil { + return nil, fmt.Errorf("cloudflare: record unmarshaling error: %v", err) + } + + for _, rec := range records { + fmt.Println(rec.Name, acme.UnFqdn(fqdn)) + if rec.Name == acme.UnFqdn(fqdn) { + return &rec, nil + } + } + + return nil, fmt.Errorf("cloudflare: no existing record found for %s", fqdn) +} + +// AddTxtRecord add a TXT record +func (c *Client) AddTxtRecord(fqdn string, record TxtRecord) error { + zoneID, err := c.GetHostedZoneID(fqdn) + if err != nil { + return err + } + + body, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("cloudflare: record marshaling error: %v", err) + } + + _, err = c.doRequest(http.MethodPost, fmt.Sprintf("/zones/%s/dns_records", zoneID), bytes.NewReader(body)) + return err +} + +// RemoveTxtRecord Remove a TXT record +func (c *Client) RemoveTxtRecord(fqdn string) error { + zoneID, err := c.GetHostedZoneID(fqdn) + if err != nil { + return err + } + + record, err := c.FindTxtRecord(zoneID, fqdn) + if err != nil { + return err + } + + _, err = c.doRequest(http.MethodDelete, fmt.Sprintf("/zones/%s/dns_records/%s", record.ZoneID, record.ID), nil) + return err +} + +func (c *Client) doRequest(method, uri string, body io.Reader) (json.RawMessage, error) { + req, err := http.NewRequest(method, fmt.Sprintf("%s%s", c.BaseURL, uri), body) + if err != nil { + return nil, err + } + + req.Header.Set("X-Auth-Email", c.authEmail) + req.Header.Set("X-Auth-Key", c.authKey) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cloudflare: error querying API: %v", err) + } + + defer resp.Body.Close() + + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("cloudflare: %s", toUnreadableBodyMessage(req, content)) + } + + var r APIResponse + err = json.Unmarshal(content, &r) + if err != nil { + return nil, fmt.Errorf("cloudflare: APIResponse unmarshaling error: %v: %s", err, toUnreadableBodyMessage(req, content)) + } + + if !r.Success { + if len(r.Errors) > 0 { + return nil, fmt.Errorf("cloudflare: error \n%s", toError(r)) + } + + return nil, fmt.Errorf("cloudflare: %s", toUnreadableBodyMessage(req, content)) + } + + return r.Result, nil +} + +func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string { + return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody)) +} + +func toError(r APIResponse) error { + 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 fmt.Errorf("cloudflare: error \n%s", errStr) +} diff --git a/providers/dns/cloudflare/client_test.go b/providers/dns/cloudflare/client_test.go new file mode 100644 index 00000000..1025f8da --- /dev/null +++ b/providers/dns/cloudflare/client_test.go @@ -0,0 +1,188 @@ +package cloudflare + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func handlerMock(method string, response *APIResponse, data interface{}) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + content, err := json.Marshal(APIResponse{ + Success: false, + Errors: []*APIError{ + { + Code: 666, + Message: fmt.Sprintf("invalid method: got %s want %s", req.Method, method), + ErrorChain: nil, + }, + }, + }) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + http.Error(rw, string(content), http.StatusBadRequest) + return + } + + jsonData, err := json.Marshal(data) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + response.Result = jsonData + + content, err := json.Marshal(response) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + rw.Write(content) + }) +} + +func TestClient_GetHostedZoneID(t *testing.T) { + type result struct { + zoneID string + error bool + } + + testCases := []struct { + desc string + fqdn string + response *APIResponse + data []HostedZone + expected result + }{ + { + desc: "zone found", + fqdn: "_acme-challenge.foo.com.", + response: &APIResponse{Success: true}, + data: []HostedZone{ + { + ID: "A", + Name: "ZONE_A", + }, + }, + expected: result{zoneID: "A"}, + }, + { + desc: "no many zones", + fqdn: "_acme-challenge.foo.com.", + response: &APIResponse{Success: true}, + data: []HostedZone{ + { + ID: "A", + Name: "ZONE_A", + }, + { + ID: "B", + Name: "ZONE_B", + }, + }, + expected: result{error: true}, + }, + { + desc: "no zone found", + fqdn: "_acme-challenge.foo.com.", + response: &APIResponse{Success: true}, + expected: result{error: true}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + server := httptest.NewServer(handlerMock(http.MethodGet, test.response, test.data)) + + client, _ := NewClient("authEmail", "authKey") + client.BaseURL = server.URL + + zoneID, err := client.GetHostedZoneID(test.fqdn) + + if test.expected.error { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected.zoneID, zoneID) + } + }) + } +} + +func TestClient_FindTxtRecord(t *testing.T) { + type result struct { + txtRecord *TxtRecord + error bool + } + + testCases := []struct { + desc string + fqdn string + zoneID string + response *APIResponse + data []TxtRecord + expected result + }{ + { + desc: "TXT record found", + fqdn: "_acme-challenge.foo.com.", + zoneID: "ZONE_A", + response: &APIResponse{Success: true}, + data: []TxtRecord{ + { + Name: "_acme-challenge.foo.com", + Type: "TXT", + Content: "txtTXTtxtTXTtxtTXTtxtTXT", + ID: "A", + TTL: 50, + ZoneID: "ZONE_A", + }, + }, + expected: result{ + txtRecord: &TxtRecord{ + Name: "_acme-challenge.foo.com", + Type: "TXT", + Content: "txtTXTtxtTXTtxtTXTtxtTXT", + ID: "A", + TTL: 50, + ZoneID: "ZONE_A", + }, + }, + }, + { + desc: "TXT record not found", + fqdn: "_acme-challenge.foo.com.", + zoneID: "ZONE_A", + response: &APIResponse{Success: true}, + expected: result{error: true}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + server := httptest.NewServer(handlerMock(http.MethodGet, test.response, test.data)) + + client, _ := NewClient("authEmail", "authKey") + client.BaseURL = server.URL + + txtRecord, err := client.FindTxtRecord(test.zoneID, test.fqdn) + + if test.expected.error { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.expected.txtRecord, txtRecord) + } + }) + } +} diff --git a/providers/dns/cloudflare/cloudflare.go b/providers/dns/cloudflare/cloudflare.go index 7d8979ef..25d6fef3 100644 --- a/providers/dns/cloudflare/cloudflare.go +++ b/providers/dns/cloudflare/cloudflare.go @@ -3,12 +3,8 @@ package cloudflare import ( - "bytes" - "encoding/json" "errors" "fmt" - "io" - "io/ioutil" "net/http" "time" @@ -17,208 +13,108 @@ import ( ) // CloudFlareAPIURL represents the API endpoint to call. -// TODO: Unexport? -const CloudFlareAPIURL = "https://api.cloudflare.com/client/v4" +const CloudFlareAPIURL = defaultBaseURL // Deprecated + +// Config is used to configure the creation of the DNSProvider +type Config struct { + AuthEmail string + AuthKey string + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("CLOUDFLARE_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("CLOUDFLARE_PROPAGATION_TIMEOUT", 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond("CLOUDFLARE_POLLING_INTERVAL", 2*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("CLOUDFLARE_HTTP_TIMEOUT", 30*time.Second), + }, + } +} // DNSProvider is an implementation of the acme.ChallengeProvider interface type DNSProvider struct { - authEmail string - authKey string - client *http.Client + client *Client + config *Config } -// NewDNSProvider returns a DNSProvider instance configured for cloudflare. -// Credentials must be passed in the environment variables: CLOUDFLARE_EMAIL -// and CLOUDFLARE_API_KEY. +// 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) { values, err := env.Get("CLOUDFLARE_EMAIL", "CLOUDFLARE_API_KEY") if err != nil { - return nil, fmt.Errorf("CloudFlare: %v", err) + return nil, fmt.Errorf("cloudflare: %v", err) } - return NewDNSProviderCredentials(values["CLOUDFLARE_EMAIL"], values["CLOUDFLARE_API_KEY"]) + config := NewDefaultConfig() + config.AuthEmail = values["CLOUDFLARE_EMAIL"] + config.AuthKey = values["CLOUDFLARE_API_KEY"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for cloudflare. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for Cloudflare. +// Deprecated func NewDNSProviderCredentials(email, key string) (*DNSProvider, error) { - if email == "" || key == "" { - return nil, errors.New("CloudFlare: some credentials information are missing") + config := NewDefaultConfig() + config.AuthEmail = email + config.AuthKey = key + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Cloudflare. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("cloudflare: the configuration of the DNS provider is nil") } + client, err := NewClient(config.AuthEmail, config.AuthKey) + if err != nil { + return nil, err + } + + client.HTTPClient = config.HTTPClient + + // TODO: must be remove. keep only for compatibility reason. + client.BaseURL = CloudFlareAPIURL + return &DNSProvider{ - authEmail: email, - authKey: key, - client: &http.Client{Timeout: 30 * time.Second}, + client: client, + config: config, }, nil } // Timeout returns the timeout and interval to use when checking for DNS // propagation. Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return 120 * time.Second, 2 * time.Second + return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfil the dns-01 challenge func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) - zoneID, err := d.getHostedZoneID(fqdn) - if err != nil { - return err - } + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) - rec := cloudFlareRecord{ + rec := TxtRecord{ Type: "TXT", Name: acme.UnFqdn(fqdn), Content: value, - TTL: ttl, + TTL: d.config.TTL, } - body, err := json.Marshal(rec) - if err != nil { - return err - } - - _, err = d.doRequest(http.MethodPost, fmt.Sprintf("/zones/%s/dns_records", zoneID), bytes.NewReader(body)) - return err + return d.client.AddTxtRecord(fqdn, rec) } // CleanUp removes the TXT record matching the specified parameters func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { fqdn, _, _ := acme.DNS01Record(domain, keyAuth) - record, err := d.findTxtRecord(fqdn) - if err != nil { - return err - } - - _, err = d.doRequest(http.MethodDelete, fmt.Sprintf("/zones/%s/dns_records/%s", record.ZoneID, record.ID), nil) - return err -} - -func (d *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 := d.doRequest(http.MethodGet, "/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 (d *DNSProvider) findTxtRecord(fqdn string) (*cloudFlareRecord, error) { - zoneID, err := d.getHostedZoneID(fqdn) - if err != nil { - return nil, err - } - - result, err := d.doRequest( - http.MethodGet, - 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 (d *DNSProvider) doRequest(method, uri string, body io.Reader) (json.RawMessage, error) { - req, err := http.NewRequest(method, fmt.Sprintf("%s%s", CloudFlareAPIURL, uri), body) - if err != nil { - return nil, err - } - - req.Header.Set("X-Auth-Email", d.authEmail) - req.Header.Set("X-Auth-Key", d.authKey) - - resp, err := d.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) - } - strBody := "Unreadable body" - if body, err := ioutil.ReadAll(resp.Body); err == nil { - strBody = string(body) - } - return nil, fmt.Errorf("Cloudflare API error: the request %s sent a response with a body which is not in JSON format: %s", req.URL.String(), strBody) - } - - return r.Result, nil -} - -// 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"` -} - -// 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"` + return d.client.RemoveTxtRecord(fqdn) } diff --git a/providers/dns/cloudflare/cloudflare_test.go b/providers/dns/cloudflare/cloudflare_test.go index 18affcd0..7fd10704 100644 --- a/providers/dns/cloudflare/cloudflare_test.go +++ b/providers/dns/cloudflare/cloudflare_test.go @@ -34,7 +34,11 @@ func TestNewDNSProviderValid(t *testing.T) { os.Setenv("CLOUDFLARE_API_KEY", "") defer restoreEnv() - _, err := NewDNSProviderCredentials("123", "123") + config := NewDefaultConfig() + config.AuthEmail = "123" + config.AuthKey = "123" + + _, err := NewDNSProviderConfig(config) assert.NoError(t, err) } @@ -54,7 +58,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { os.Setenv("CLOUDFLARE_API_KEY", "") _, err := NewDNSProvider() - assert.EqualError(t, err, "CloudFlare: some credentials information are missing: CLOUDFLARE_EMAIL,CLOUDFLARE_API_KEY") + assert.EqualError(t, err, "cloudflare: some credentials information are missing: CLOUDFLARE_EMAIL,CLOUDFLARE_API_KEY") } func TestNewDNSProviderMissingCredErrSingle(t *testing.T) { @@ -62,7 +66,7 @@ func TestNewDNSProviderMissingCredErrSingle(t *testing.T) { os.Setenv("CLOUDFLARE_EMAIL", "awesome@possum.com") _, err := NewDNSProvider() - assert.EqualError(t, err, "CloudFlare: some credentials information are missing: CLOUDFLARE_API_KEY") + assert.EqualError(t, err, "cloudflare: some credentials information are missing: CLOUDFLARE_API_KEY") } func TestCloudFlarePresent(t *testing.T) { @@ -70,7 +74,11 @@ func TestCloudFlarePresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderCredentials(cflareEmail, cflareAPIKey) + config := NewDefaultConfig() + config.AuthEmail = cflareEmail + config.AuthKey = cflareAPIKey + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.Present(cflareDomain, "", "123d==") @@ -84,7 +92,11 @@ func TestCloudFlareCleanUp(t *testing.T) { time.Sleep(time.Second * 2) - provider, err := NewDNSProviderCredentials(cflareEmail, cflareAPIKey) + config := NewDefaultConfig() + config.AuthEmail = cflareEmail + config.AuthKey = cflareAPIKey + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.CleanUp(cflareDomain, "", "123d==") diff --git a/providers/dns/digitalocean/client.go b/providers/dns/digitalocean/client.go new file mode 100644 index 00000000..d2f6c95f --- /dev/null +++ b/providers/dns/digitalocean/client.go @@ -0,0 +1,26 @@ +package digitalocean + +const defaultBaseURL = "https://api.digitalocean.com" + +// 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"` + TTL int `json:"ttl"` +} + +// 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"` +} + +type digitalOceanAPIError struct { + ID string `json:"id"` + Message string `json:"message"` +} diff --git a/providers/dns/digitalocean/digitalocean.go b/providers/dns/digitalocean/digitalocean.go index e4247046..97620dbf 100644 --- a/providers/dns/digitalocean/digitalocean.go +++ b/providers/dns/digitalocean/digitalocean.go @@ -5,7 +5,10 @@ package digitalocean import ( "bytes" "encoding/json" + "errors" "fmt" + "io" + "io/ioutil" "net/http" "sync" "time" @@ -14,13 +17,35 @@ import ( "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + BaseURL string + AuthToken string + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + BaseURL: defaultBaseURL, + TTL: env.GetOrDefaultInt("DO_TTL", 30), + PropagationTimeout: env.GetOrDefaultSecond("DO_PROPAGATION_TIMEOUT", 60*time.Second), + PollingInterval: env.GetOrDefaultSecond("DO_POLLING_INTERVAL", 5*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("DO_HTTP_TIMEOUT", 30*time.Second), + }, + } +} + // 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 - client *http.Client + config *Config + recordIDs map[string]int + recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Digital @@ -29,74 +54,60 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("DO_AUTH_TOKEN") if err != nil { - return nil, fmt.Errorf("DigitalOcean: %v", err) + return nil, fmt.Errorf("digitalocean: %v", err) } - return NewDNSProviderCredentials(values["DO_AUTH_TOKEN"]) + config := NewDefaultConfig() + config.AuthToken = values["DO_AUTH_TOKEN"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for Digital Ocean. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for Digital Ocean. +// Deprecated func NewDNSProviderCredentials(apiAuthToken string) (*DNSProvider, error) { - if apiAuthToken == "" { - return nil, fmt.Errorf("DigitalOcean credentials missing") + config := NewDefaultConfig() + config.AuthToken = apiAuthToken + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Digital Ocean. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("digitalocean: the configuration of the DNS provider is nil") } + + if config.AuthToken == "" { + return nil, fmt.Errorf("digitalocean: credentials missing") + } + + if config.BaseURL == "" { + config.BaseURL = defaultBaseURL + } + return &DNSProvider{ - apiAuthToken: apiAuthToken, - recordIDs: make(map[string]int), - client: &http.Client{Timeout: 30 * time.Second}, + config: config, + recordIDs: make(map[string]int), }, nil } -// Timeout returns the timeout and interval to use when checking for DNS -// propagation. Adjusting here to cope with spikes in propagation times. +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return 60 * time.Second, 5 * time.Second + return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters func (d *DNSProvider) Present(domain, token, keyAuth string) error { fqdn, value, _ := acme.DNS01Record(domain, keyAuth) - authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) + respData, err := d.addTxtRecord(domain, fqdn, value) if err != nil { - return fmt.Errorf("could not determine zone for domain: '%s'. %s", domain, err) + return fmt.Errorf("digitalocean: %v", err) } - authZone = acme.UnFqdn(authZone) - - reqURL := fmt.Sprintf("%s/v2/domains/%s/records", digitalOceanBaseURL, authZone) - reqData := txtRecordRequest{RecordType: "TXT", Name: fqdn, Data: value, TTL: 30} - body, err := json.Marshal(reqData) - if err != nil { - return err - } - - req, err := http.NewRequest(http.MethodPost, 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)) - - resp, err := d.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() @@ -113,35 +124,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { recordID, ok := d.recordIDs[fqdn] d.recordIDsMu.Unlock() if !ok { - return fmt.Errorf("unknown record ID for '%s'", fqdn) + return fmt.Errorf("digitalocean: unknown record ID for '%s'", fqdn) } - authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) + err := d.removeTxtRecord(domain, recordID) 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(http.MethodDelete, reqURL, nil) - if err != nil { - return err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.apiAuthToken)) - - resp, err := d.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) + return fmt.Errorf("digitalocean: %v", err) } // Delete record ID from map @@ -152,27 +140,101 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } -type digitalOceanAPIError struct { - ID string `json:"id"` - Message string `json:"message"` +func (d *DNSProvider) removeTxtRecord(domain string, recordID int) error { + 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) + } + + reqURL := fmt.Sprintf("%s/v2/domains/%s/records/%d", d.config.BaseURL, acme.UnFqdn(authZone), recordID) + req, err := d.newRequest(http.MethodDelete, reqURL, nil) + if err != nil { + return err + } + + resp, err := d.config.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return readError(req, resp) + } + + return nil } -var digitalOceanBaseURL = "https://api.digitalocean.com" +func (d *DNSProvider) addTxtRecord(domain, fqdn, value string) (*txtRecordResponse, error) { + authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) + if err != nil { + return nil, fmt.Errorf("could not determine zone for domain: '%s'. %s", domain, err) + } -// 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"` - TTL int `json:"ttl"` + reqData := txtRecordRequest{RecordType: "TXT", Name: fqdn, Data: value, TTL: d.config.TTL} + body, err := json.Marshal(reqData) + if err != nil { + return nil, err + } + + reqURL := fmt.Sprintf("%s/v2/domains/%s/records", d.config.BaseURL, acme.UnFqdn(authZone)) + req, err := d.newRequest(http.MethodPost, reqURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + resp, err := d.config.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, readError(req, resp) + } + + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, errors.New(toUnreadableBodyMessage(req, content)) + } + + // Everything looks good; but we'll need the ID later to delete the record + respData := &txtRecordResponse{} + err = json.Unmarshal(content, respData) + if err != nil { + return nil, fmt.Errorf("%v: %s", err, toUnreadableBodyMessage(req, content)) + } + + return respData, nil } -// 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"` +func (d *DNSProvider) newRequest(method, reqURL string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, reqURL, body) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.config.AuthToken)) + + return req, nil +} + +func readError(req *http.Request, resp *http.Response) error { + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + return errors.New(toUnreadableBodyMessage(req, content)) + } + + var errInfo digitalOceanAPIError + err = json.Unmarshal(content, &errInfo) + if err != nil { + return fmt.Errorf("digitalOceanAPIError unmarshaling error: %v: %s", err, toUnreadableBodyMessage(req, content)) + } + + return fmt.Errorf("HTTP %d: %s: %s", resp.StatusCode, errInfo.ID, errInfo.Message) +} + +func toUnreadableBodyMessage(req *http.Request, rawBody []byte) string { + return fmt.Sprintf("the request %s sent a response with a body which is an invalid format: %q", req.URL, string(rawBody)) } diff --git a/providers/dns/digitalocean/digitalocean_test.go b/providers/dns/digitalocean/digitalocean_test.go index 7a77f894..49a1be92 100644 --- a/providers/dns/digitalocean/digitalocean_test.go +++ b/providers/dns/digitalocean/digitalocean_test.go @@ -42,13 +42,16 @@ func TestDigitalOceanPresent(t *testing.T) { }`) })) defer mock.Close() - digitalOceanBaseURL = mock.URL - doprov, err := NewDNSProviderCredentials(fakeDigitalOceanAuth) + config := NewDefaultConfig() + config.AuthToken = fakeDigitalOceanAuth + config.BaseURL = mock.URL + + provider, err := NewDNSProviderConfig(config) require.NoError(t, err) - require.NotNil(t, doprov) + require.NotNil(t, provider) - err = doprov.Present("example.com", "", "foobar") + err = provider.Present("example.com", "", "foobar") require.NoError(t, err, "fail to create TXT record") assert.True(t, requestReceived, "Expected request to be received by mock backend, but it wasn't") @@ -69,17 +72,20 @@ func TestDigitalOceanCleanUp(t *testing.T) { w.WriteHeader(http.StatusNoContent) })) defer mock.Close() - digitalOceanBaseURL = mock.URL - doprov, err := NewDNSProviderCredentials(fakeDigitalOceanAuth) + config := NewDefaultConfig() + config.AuthToken = fakeDigitalOceanAuth + config.BaseURL = mock.URL + + provider, err := NewDNSProviderConfig(config) require.NoError(t, err) - require.NotNil(t, doprov) + require.NotNil(t, provider) - doprov.recordIDsMu.Lock() - doprov.recordIDs["_acme-challenge.example.com."] = 1234567 - doprov.recordIDsMu.Unlock() + provider.recordIDsMu.Lock() + provider.recordIDs["_acme-challenge.example.com."] = 1234567 + provider.recordIDsMu.Unlock() - err = doprov.CleanUp("example.com", "", "") + err = provider.CleanUp("example.com", "", "") require.NoError(t, err, "fail to remove TXT record") assert.True(t, requestReceived, "Expected request to be received by mock backend, but it wasn't") diff --git a/providers/dns/dnsimple/dnsimple.go b/providers/dns/dnsimple/dnsimple.go index a9f8424a..c9c9ce3d 100644 --- a/providers/dns/dnsimple/dnsimple.go +++ b/providers/dns/dnsimple/dnsimple.go @@ -3,17 +3,39 @@ package dnsimple import ( + "errors" "fmt" "os" "strconv" "strings" + "time" "github.com/dnsimple/dnsimple-go/dnsimple" "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + AccessToken string + BaseURL string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("DNSIMPLE_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("DNSIMPLE_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("DNSIMPLE_POLLING_INTERVAL", acme.DefaultPollingInterval), + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface. type DNSProvider struct { + config *Config client *dnsimple.Client } @@ -22,24 +44,39 @@ type DNSProvider struct { // // See: https://developer.dnsimple.com/v2/#authentication func NewDNSProvider() (*DNSProvider, error) { - accessToken := os.Getenv("DNSIMPLE_OAUTH_TOKEN") - baseURL := os.Getenv("DNSIMPLE_BASE_URL") + config := NewDefaultConfig() + config.AccessToken = os.Getenv("DNSIMPLE_OAUTH_TOKEN") + config.BaseURL = os.Getenv("DNSIMPLE_BASE_URL") - return NewDNSProviderCredentials(accessToken, baseURL) + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for dnsimple. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for DNSimple. +// Deprecated func NewDNSProviderCredentials(accessToken, baseURL string) (*DNSProvider, error) { - if accessToken == "" { - return nil, fmt.Errorf("DNSimple OAuth token is missing") + config := NewDefaultConfig() + config.AccessToken = accessToken + config.BaseURL = baseURL + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for DNSimple. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("dnsimple: the configuration of the DNS provider is nil") } - client := dnsimple.NewClient(dnsimple.NewOauthTokenCredentials(accessToken)) - client.UserAgent = "lego" + if config.AccessToken == "" { + return nil, fmt.Errorf("dnsimple: OAuth token is missing") + } - if baseURL != "" { - client.BaseURL = baseURL + client := dnsimple.NewClient(dnsimple.NewOauthTokenCredentials(config.AccessToken)) + client.UserAgent = acme.UserAgent + + if config.BaseURL != "" { + client.BaseURL = config.BaseURL } return &DNSProvider{client: client}, nil @@ -47,10 +84,9 @@ func NewDNSProviderCredentials(accessToken, baseURL string) (*DNSProvider, error // Present creates a TXT record to fulfil the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) zoneName, err := d.getHostedZone(domain) - if err != nil { return err } @@ -60,10 +96,10 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return err } - recordAttributes := d.newTxtRecord(zoneName, fqdn, value, ttl) - _, err = d.client.Zones.CreateRecord(accountID, zoneName, *recordAttributes) + recordAttributes := d.newTxtRecord(zoneName, fqdn, value, d.config.TTL) + _, err = d.client.Zones.CreateRecord(accountID, zoneName, recordAttributes) if err != nil { - return fmt.Errorf("DNSimple API call failed: %v", err) + return fmt.Errorf("API call failed: %v", err) } return nil @@ -93,6 +129,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + func (d *DNSProvider) getHostedZone(domain string) (string, error) { authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) if err != nil { @@ -108,7 +150,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) { zones, err := d.client.Zones.ListZones(accountID, &dnsimple.ZoneListOptions{NameLike: zoneName}) if err != nil { - return "", fmt.Errorf("DNSimple API call failed: %v", err) + return "", fmt.Errorf("API call failed: %v", err) } var hostedZone dnsimple.Zone @@ -140,16 +182,16 @@ func (d *DNSProvider) findTxtRecords(domain, fqdn string) ([]dnsimple.ZoneRecord result, err := d.client.Zones.ListRecords(accountID, zoneName, &dnsimple.ZoneRecordListOptions{Name: recordName, Type: "TXT", ListOptions: dnsimple.ListOptions{}}) if err != nil { - return []dnsimple.ZoneRecord{}, fmt.Errorf("DNSimple API call has failed: %v", err) + return []dnsimple.ZoneRecord{}, fmt.Errorf("API call has failed: %v", err) } return result.Data, nil } -func (d *DNSProvider) newTxtRecord(zoneName, fqdn, value string, ttl int) *dnsimple.ZoneRecord { +func (d *DNSProvider) newTxtRecord(zoneName, fqdn, value string, ttl int) dnsimple.ZoneRecord { name := d.extractRecordName(fqdn, zoneName) - return &dnsimple.ZoneRecord{ + return dnsimple.ZoneRecord{ Type: "TXT", Name: name, Content: value, @@ -172,7 +214,7 @@ func (d *DNSProvider) getAccountID() (string, error) { } if whoamiResponse.Data.Account == nil { - return "", fmt.Errorf("DNSimple user tokens are not supported, please use an account token") + return "", fmt.Errorf("user tokens are not supported, please use an account token") } return strconv.FormatInt(whoamiResponse.Data.Account.ID, 10), nil diff --git a/providers/dns/dnsimple/dnsimple_test.go b/providers/dns/dnsimple/dnsimple_test.go index 048fe217..c3780c0c 100644 --- a/providers/dns/dnsimple/dnsimple_test.go +++ b/providers/dns/dnsimple/dnsimple_test.go @@ -6,6 +6,8 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xenolf/lego/acme" ) var ( @@ -44,6 +46,8 @@ func TestNewDNSProviderValid(t *testing.T) { defer restoreEnv() os.Setenv("DNSIMPLE_OAUTH_TOKEN", "123") + acme.UserAgent = "lego" + provider, err := NewDNSProvider() assert.NotNil(t, provider) @@ -71,7 +75,7 @@ func TestNewDNSProviderInvalidWithMissingOauthToken(t *testing.T) { provider, err := NewDNSProvider() assert.Nil(t, provider) - assert.EqualError(t, err, "DNSimple OAuth token is missing") + assert.EqualError(t, err, "dnsimple: OAuth token is missing") } // @@ -79,27 +83,39 @@ func TestNewDNSProviderInvalidWithMissingOauthToken(t *testing.T) { // func TestNewDNSProviderCredentialsValid(t *testing.T) { - provider, err := NewDNSProviderCredentials("123", "") + config := NewDefaultConfig() + config.AccessToken = "123" + config.BaseURL = "" + + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) + require.NotNil(t, provider) - assert.NotNil(t, provider) assert.Equal(t, "lego", provider.client.UserAgent) assert.NoError(t, err) } func TestNewDNSProviderCredentialsValidWithBaseUrl(t *testing.T) { - provider, err := NewDNSProviderCredentials("123", "https://api.dnsimple.test") + config := NewDefaultConfig() + config.AccessToken = "123" + config.BaseURL = "https://api.dnsimple.test" - assert.NotNil(t, provider) - assert.NoError(t, err) + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) + require.NotNil(t, provider) assert.Equal(t, provider.client.BaseURL, "https://api.dnsimple.test") } func TestNewDNSProviderCredentialsInvalidWithMissingOauthToken(t *testing.T) { - provider, err := NewDNSProviderCredentials("", "") + config := NewDefaultConfig() + config.AccessToken = "" + config.BaseURL = "" + + provider, err := NewDNSProviderConfig(config) assert.Nil(t, provider) - assert.EqualError(t, err, "DNSimple OAuth token is missing") + assert.EqualError(t, err, "dnsimple: OAuth token is missing") } // @@ -111,7 +127,11 @@ func TestLiveDNSimplePresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderCredentials(dnsimpleOauthToken, dnsimpleBaseURL) + config := NewDefaultConfig() + config.AccessToken = dnsimpleOauthToken + config.BaseURL = dnsimpleBaseURL + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.Present(dnsimpleDomain, "", "123d==") @@ -129,7 +149,11 @@ func TestLiveDNSimpleCleanUp(t *testing.T) { time.Sleep(time.Second * 1) - provider, err := NewDNSProviderCredentials(dnsimpleOauthToken, dnsimpleBaseURL) + config := NewDefaultConfig() + config.AccessToken = dnsimpleOauthToken + config.BaseURL = dnsimpleBaseURL + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.CleanUp(dnsimpleDomain, "", "123d==") diff --git a/providers/dns/dnsmadeeasy/client.go b/providers/dns/dnsmadeeasy/client.go new file mode 100644 index 00000000..54e87dcc --- /dev/null +++ b/providers/dns/dnsmadeeasy/client.go @@ -0,0 +1,168 @@ +package dnsmadeeasy + +import ( + "bytes" + "crypto/hmac" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// 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"` +} + +// Client DNSMadeEasy client +type Client struct { + apiKey string + apiSecret string + BaseURL string + HTTPClient *http.Client +} + +// NewClient creates a DNSMadeEasy client +func NewClient(apiKey string, apiSecret string) (*Client, error) { + if apiKey == "" { + return nil, fmt.Errorf("DNSMadeEasy: credentials missing: API key") + } + + if apiSecret == "" { + return nil, fmt.Errorf("DNSMadeEasy: credentials missing: API secret") + } + + return &Client{ + apiKey: apiKey, + apiSecret: apiSecret, + HTTPClient: &http.Client{}, + }, nil +} + +// GetDomain gets a domain +func (c *Client) GetDomain(authZone string) (*Domain, error) { + domainName := authZone[0 : len(authZone)-1] + resource := fmt.Sprintf("%s%s", "/dns/managed/name?domainname=", domainName) + + resp, err := c.sendRequest(http.MethodGet, 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 +} + +// GetRecords gets all TXT records +func (c *Client) 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 := c.sendRequest(http.MethodGet, 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 +} + +// CreateRecord creates a TXT records +func (c *Client) CreateRecord(domain *Domain, record *Record) error { + url := fmt.Sprintf("%s/%d/%s", "/dns/managed", domain.ID, "records") + + resp, err := c.sendRequest(http.MethodPost, url, record) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +// DeleteRecord deletes a TXT records +func (c *Client) DeleteRecord(record Record) error { + resource := fmt.Sprintf("%s/%d/%s/%d", "/dns/managed", record.SourceID, "records", record.ID) + + resp, err := c.sendRequest(http.MethodDelete, resource, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +func (c *Client) sendRequest(method, resource string, payload interface{}) (*http.Response, error) { + url := fmt.Sprintf("%s%s", c.BaseURL, resource) + + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + timestamp := time.Now().UTC().Format(time.RFC1123) + signature, err := computeHMAC(timestamp, c.apiSecret) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("x-dnsme-apiKey", c.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") + + resp, err := c.HTTPClient.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, error) { + key := []byte(secret) + h := hmac.New(sha1.New, key) + _, err := h.Write([]byte(message)) + if err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/providers/dns/dnsmadeeasy/dnsmadeeasy.go b/providers/dns/dnsmadeeasy/dnsmadeeasy.go index 9f7f31a9..262a3020 100644 --- a/providers/dns/dnsmadeeasy/dnsmadeeasy.go +++ b/providers/dns/dnsmadeeasy/dnsmadeeasy.go @@ -1,12 +1,8 @@ package dnsmadeeasy import ( - "bytes" - "crypto/hmac" - "crypto/sha1" "crypto/tls" - "encoding/hex" - "encoding/json" + "errors" "fmt" "net/http" "os" @@ -18,38 +14,46 @@ import ( "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + BaseURL string + APIKey string + APISecret string + HTTPClient *http.Client + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("DNSMADEEASY_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("DNSMADEEASY_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("DNSMADEEASY_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("DNSMADEEASY_HTTP_TIMEOUT", 10*time.Second), + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + }, + } +} + // 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 - client *http.Client -} - -// 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"` + config *Config + client *Client } // NewDNSProvider returns a DNSProvider instance configured for DNSMadeEasy DNS. -// Credentials must be passed in the environment variables: DNSMADEEASY_API_KEY -// and DNSMADEEASY_API_SECRET. +// Credentials must be passed in the environment variables: +// DNSMADEEASY_API_KEY and DNSMADEEASY_API_SECRET. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("DNSMADEEASY_API_KEY", "DNSMADEEASY_API_SECRET") if err != nil { - return nil, fmt.Errorf("DNSMadeEasy: %v", err) + return nil, fmt.Errorf("dnsmadeeasy: %v", err) } var baseURL string @@ -59,35 +63,53 @@ func NewDNSProvider() (*DNSProvider, error) { baseURL = "https://api.dnsmadeeasy.com/V2.0" } - return NewDNSProviderCredentials(baseURL, values["DNSMADEEASY_API_KEY"], values["DNSMADEEASY_API_SECRET"]) + config := NewDefaultConfig() + config.BaseURL = baseURL + config.APIKey = values["DNSMADEEASY_API_KEY"] + config.APISecret = values["DNSMADEEASY_API_SECRET"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for DNSMadeEasy. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for DNS Made Easy. +// Deprecated func NewDNSProviderCredentials(baseURL, apiKey, apiSecret string) (*DNSProvider, error) { - if baseURL == "" || apiKey == "" || apiSecret == "" { - return nil, fmt.Errorf("DNS Made Easy credentials missing") + config := NewDefaultConfig() + config.BaseURL = baseURL + config.APIKey = apiKey + config.APISecret = apiSecret + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for DNS Made Easy. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("dnsmadeeasy: the configuration of the DNS provider is nil") } - transport := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + if config.BaseURL == "" { + return nil, fmt.Errorf("dnsmadeeasy: base URL missing") } - client := &http.Client{ - Transport: transport, - Timeout: 10 * time.Second, + + client, err := NewClient(config.APIKey, config.APISecret) + if err != nil { + return nil, fmt.Errorf("dnsmadeeasy: %v", err) } + client.HTTPClient = config.HTTPClient + client.BaseURL = config.BaseURL + return &DNSProvider{ - baseURL: baseURL, - apiKey: apiKey, - apiSecret: apiSecret, - client: client, + client: client, + config: config, }, 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) + fqdn, value, _ := acme.DNS01Record(domainName, keyAuth) authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) if err != nil { @@ -95,16 +117,16 @@ func (d *DNSProvider) Present(domainName, token, keyAuth string) error { } // fetch the domain details - domain, err := d.getDomain(authZone) + domain, err := d.client.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} + record := &Record{Type: "TXT", Name: name, Value: value, TTL: d.config.TTL} - err = d.createRecord(domain, record) + err = d.client.CreateRecord(domain, record) return err } @@ -118,21 +140,21 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error { } // fetch the domain details - domain, err := d.getDomain(authZone) + domain, err := d.client.GetDomain(authZone) if err != nil { return err } // find matching records name := strings.Replace(fqdn, "."+authZone, "", 1) - records, err := d.getRecords(domain, name, "TXT") + records, err := d.client.GetRecords(domain, name, "TXT") if err != nil { return err } // delete records for _, record := range *records { - err = d.deleteRecord(record) + err = d.client.DeleteRecord(record) if err != nil { return err } @@ -141,107 +163,8 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error { 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(http.MethodGet, 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(http.MethodGet, 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(http.MethodPost, 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(http.MethodDelete, 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") - - resp, err := d.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)) +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } diff --git a/providers/dns/dnspod/dnspod.go b/providers/dns/dnspod/dnspod.go index e42e3633..35b74868 100644 --- a/providers/dns/dnspod/dnspod.go +++ b/providers/dns/dnspod/dnspod.go @@ -3,16 +3,42 @@ package dnspod import ( + "errors" "fmt" + "net/http" + "strconv" "strings" + "time" "github.com/decker502/dnspod-go" "github.com/xenolf/lego/acme" "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + LoginToken string + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("DNSPOD_TTL", 600), + PropagationTimeout: env.GetOrDefaultSecond("ALICLOUD_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("ALICLOUD_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("DNSPOD_HTTP_TIMEOUT", 0), + }, + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface. type DNSProvider struct { + config *Config client *dnspod.Client } @@ -21,37 +47,55 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("DNSPOD_API_KEY") if err != nil { - return nil, fmt.Errorf("DNSPod: %v", err) + return nil, fmt.Errorf("dnspod: %v", err) } - return NewDNSProviderCredentials(values["DNSPOD_API_KEY"]) + config := NewDefaultConfig() + config.LoginToken = values["DNSPOD_API_KEY"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for dnspod. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for dnspod. +// Deprecated func NewDNSProviderCredentials(key string) (*DNSProvider, error) { - if key == "" { - return nil, fmt.Errorf("dnspod credentials missing") + config := NewDefaultConfig() + config.LoginToken = key + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for dnspod. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("dnspod: the configuration of the DNS provider is nil") } - params := dnspod.CommonParams{LoginToken: key, Format: "json"} - return &DNSProvider{ - client: dnspod.NewClient(params), - }, nil + if config.LoginToken == "" { + return nil, fmt.Errorf("dnspod: credentials missing") + } + + params := dnspod.CommonParams{LoginToken: config.LoginToken, Format: "json"} + + client := dnspod.NewClient(params) + client.HttpClient = config.HTTPClient + + return &DNSProvider{client: client}, nil } // Present creates a TXT record to fulfil the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) zoneID, zoneName, err := d.getHostedZone(domain) if err != nil { return err } - recordAttributes := d.newTxtRecord(zoneName, fqdn, value, ttl) + recordAttributes := d.newTxtRecord(zoneName, fqdn, value, d.config.TTL) _, _, err = d.client.Domains.CreateRecord(zoneID, *recordAttributes) if err != nil { - return fmt.Errorf("dnspod API call failed: %v", err) + return fmt.Errorf("API call failed: %v", err) } return nil @@ -80,10 +124,16 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + func (d *DNSProvider) getHostedZone(domain string) (string, string, error) { zones, _, err := d.client.Domains.List() if err != nil { - return "", "", fmt.Errorf("dnspod API call failed: %v", err) + return "", "", fmt.Errorf("API call failed: %v", err) } authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) @@ -114,7 +164,7 @@ func (d *DNSProvider) newTxtRecord(zone, fqdn, value string, ttl int) *dnspod.Re Name: name, Value: value, Line: "默认", - TTL: "600", + TTL: strconv.Itoa(ttl), } } @@ -127,7 +177,7 @@ func (d *DNSProvider) findTxtRecords(domain, fqdn string) ([]dnspod.Record, erro var records []dnspod.Record result, _, err := d.client.Domains.ListRecords(zoneID, "") if err != nil { - return records, fmt.Errorf("dnspod API call has failed: %v", err) + return records, fmt.Errorf("API call has failed: %v", err) } recordName := d.extractRecordName(fqdn, zoneName) diff --git a/providers/dns/dnspod/dnspod_test.go b/providers/dns/dnspod/dnspod_test.go index 06ecf3c7..95fac006 100644 --- a/providers/dns/dnspod/dnspod_test.go +++ b/providers/dns/dnspod/dnspod_test.go @@ -30,7 +30,10 @@ func TestNewDNSProviderValid(t *testing.T) { defer restoreEnv() os.Setenv("DNSPOD_API_KEY", "") - _, err := NewDNSProviderCredentials("123") + config := NewDefaultConfig() + config.LoginToken = "123" + + _, err := NewDNSProviderConfig(config) assert.NoError(t, err) } func TestNewDNSProviderValidEnv(t *testing.T) { @@ -46,7 +49,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { os.Setenv("DNSPOD_API_KEY", "") _, err := NewDNSProvider() - assert.EqualError(t, err, "DNSPod: some credentials information are missing: DNSPOD_API_KEY") + assert.EqualError(t, err, "dnspod: some credentials information are missing: DNSPOD_API_KEY") } func TestLivednspodPresent(t *testing.T) { @@ -54,7 +57,10 @@ func TestLivednspodPresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderCredentials(dnspodAPIKey) + config := NewDefaultConfig() + config.LoginToken = dnspodAPIKey + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.Present(dnspodDomain, "", "123d==") @@ -68,7 +74,10 @@ func TestLivednspodCleanUp(t *testing.T) { time.Sleep(time.Second * 1) - provider, err := NewDNSProviderCredentials(dnspodAPIKey) + config := NewDefaultConfig() + config.LoginToken = dnspodAPIKey + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.CleanUp(dnspodDomain, "", "123d==") diff --git a/providers/dns/duckdns/duckdns.go b/providers/dns/duckdns/duckdns.go index a8e25ba8..7e2a55a4 100644 --- a/providers/dns/duckdns/duckdns.go +++ b/providers/dns/duckdns/duckdns.go @@ -6,15 +6,36 @@ import ( "errors" "fmt" "io/ioutil" + "net/http" + "time" "github.com/xenolf/lego/acme" "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + Token string + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + client := acme.HTTPClient + client.Timeout = env.GetOrDefaultSecond("DUCKDNS_HTTP_TIMEOUT", 30*time.Second) + + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond("DUCKDNS_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("DUCKDNS_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &client, + } +} + // DNSProvider adds and removes the record for the DNS challenge type DNSProvider struct { - // The api token - token string + config *Config } // NewDNSProvider returns a new DNS provider using @@ -22,31 +43,53 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("DUCKDNS_TOKEN") if err != nil { - return nil, fmt.Errorf("DuckDNS: %v", err) + return nil, fmt.Errorf("duckdns: %v", err) } - return NewDNSProviderCredentials(values["DUCKDNS_TOKEN"]) + config := NewDefaultConfig() + config.Token = values["DUCKDNS_TOKEN"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for http://duckdns.org . +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for http://duckdns.org +// Deprecated func NewDNSProviderCredentials(token string) (*DNSProvider, error) { - if token == "" { - return nil, errors.New("DuckDNS: credentials missing") + config := NewDefaultConfig() + config.Token = token + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for DuckDNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("duckdns: the configuration of the DNS provider is nil") } - return &DNSProvider{token: token}, nil + if config.Token == "" { + return nil, errors.New("duckdns: credentials missing") + } + + return &DNSProvider{config: config}, nil } // Present creates a TXT record to fulfil the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { _, txtRecord, _ := acme.DNS01Record(domain, keyAuth) - return updateTxtRecord(domain, d.token, txtRecord, false) + return updateTxtRecord(domain, d.config.Token, txtRecord, false) } // CleanUp clears DuckDNS TXT record func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - return updateTxtRecord(domain, d.token, "", true) + return updateTxtRecord(domain, d.config.Token, "", true) +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } // updateTxtRecord Update the domains TXT record diff --git a/providers/dns/duckdns/duckdns_test.go b/providers/dns/duckdns/duckdns_test.go index 5c184702..a078a26d 100644 --- a/providers/dns/duckdns/duckdns_test.go +++ b/providers/dns/duckdns/duckdns_test.go @@ -39,7 +39,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { os.Setenv("DUCKDNS_TOKEN", "") _, err := NewDNSProvider() - assert.EqualError(t, err, "DuckDNS: some credentials information are missing: DUCKDNS_TOKEN") + assert.EqualError(t, err, "duckdns: some credentials information are missing: DUCKDNS_TOKEN") } func TestLiveDuckdnsPresent(t *testing.T) { diff --git a/providers/dns/dyn/client.go b/providers/dns/dyn/client.go new file mode 100644 index 00000000..1ca7b8e5 --- /dev/null +++ b/providers/dns/dyn/client.go @@ -0,0 +1,35 @@ +package dyn + +import "encoding/json" + +const defaultBaseURL = "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"` +} + +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"` +} + +type publish struct { + Publish bool `json:"publish"` + Notes string `json:"notes"` +} diff --git a/providers/dns/dyn/dyn.go b/providers/dns/dyn/dyn.go index 187b1b48..8ec7753f 100644 --- a/providers/dns/dyn/dyn.go +++ b/providers/dns/dyn/dyn.go @@ -5,6 +5,7 @@ package dyn import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -14,122 +15,166 @@ import ( "github.com/xenolf/lego/platform/config/env" ) -var dynBaseURL = "https://api.dynect.net/REST" +// Config is used to configure the creation of the DNSProvider +type Config struct { + CustomerName string + UserName string + Password string + HTTPClient *http.Client + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} -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"` +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("DYN_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("DYN_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("DYN_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("DYN_HTTP_TIMEOUT", 10*time.Second), + }, + } } // 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 - client *http.Client + config *Config + 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. +// Credentials must be passed in the environment variables: +// DYN_CUSTOMER_NAME, DYN_USER_NAME and DYN_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("DYN_CUSTOMER_NAME", "DYN_USER_NAME", "DYN_PASSWORD") if err != nil { - return nil, fmt.Errorf("DynDNS: %v", err) + return nil, fmt.Errorf("dyn: %v", err) } - return NewDNSProviderCredentials(values["DYN_CUSTOMER_NAME"], values["DYN_USER_NAME"], values["DYN_PASSWORD"]) + config := NewDefaultConfig() + config.CustomerName = values["DYN_CUSTOMER_NAME"] + config.UserName = values["DYN_USER_NAME"] + config.Password = values["DYN_PASSWORD"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for Dyn DNS. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for Dyn DNS. +// Deprecated func NewDNSProviderCredentials(customerName, userName, password string) (*DNSProvider, error) { - if customerName == "" || userName == "" || password == "" { - return nil, fmt.Errorf("DynDNS credentials missing") - } + config := NewDefaultConfig() + config.CustomerName = customerName + config.UserName = userName + config.Password = password - return &DNSProvider{ - customerName: customerName, - userName: userName, - password: password, - client: &http.Client{Timeout: 10 * time.Second}, - }, nil + return NewDNSProviderConfig(config) } -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 +// NewDNSProviderConfig return a DNSProvider instance configured for Dyn DNS +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("dyn: the configuration of the DNS provider is nil") } - req, err := http.NewRequest(method, url, bytes.NewReader(body)) - if err != nil { - return nil, err + if config.CustomerName == "" || config.UserName == "" || config.Password == "" { + return nil, fmt.Errorf("dyn: credentials missing") } + + return &DNSProvider{config: config}, nil +} + +// Present creates a TXT record using the specified parameters +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return fmt.Errorf("dyn: %v", err) + } + + err = d.login() + if err != nil { + return fmt.Errorf("dyn: %v", err) + } + + data := map[string]interface{}{ + "rdata": map[string]string{ + "txtdata": value, + }, + "ttl": strconv.Itoa(d.config.TTL), + } + + resource := fmt.Sprintf("TXTRecord/%s/%s/", authZone, fqdn) + _, err = d.sendRequest(http.MethodPost, resource, data) + if err != nil { + return fmt.Errorf("dyn: %v", err) + } + + err = d.publish(authZone, "Added TXT record for ACME dns-01 challenge using lego client") + if err != nil { + return fmt.Errorf("dyn: %v", err) + } + + return d.logout() +} + +// 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 fmt.Errorf("dyn: %v", err) + } + + err = d.login() + if err != nil { + return fmt.Errorf("dyn: %v", err) + } + + resource := fmt.Sprintf("TXTRecord/%s/%s/", authZone, fqdn) + url := fmt.Sprintf("%s/%s", defaultBaseURL, resource) + + req, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return fmt.Errorf("dyn: %v", err) + } + req.Header.Set("Content-Type", "application/json") - if len(d.token) > 0 { - req.Header.Set("Auth-Token", d.token) - } + req.Header.Set("Auth-Token", d.token) - resp, err := d.client.Do(req) + resp, err := d.config.HTTPClient.Do(req) if err != nil { - return nil, err + return fmt.Errorf("dyn: %v", err) } - defer resp.Body.Close() + resp.Body.Close() - if resp.StatusCode >= 500 { - return nil, fmt.Errorf("Dyn API request failed with HTTP status code %d", resp.StatusCode) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("dyn: API request failed to delete TXT record HTTP status code %d", resp.StatusCode) } - var dynRes dynResponse - err = json.NewDecoder(resp.Body).Decode(&dynRes) + err = d.publish(authZone, "Removed TXT record for ACME dns-01 challenge using lego client") if err != nil { - return nil, err + return fmt.Errorf("dyn: %v", err) } - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("Dyn API request failed with HTTP status code %d: %s", resp.StatusCode, dynRes.Messages) - } 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") - } + return d.logout() +} - if dynRes.Status == "failure" { - // TODO add better error handling - return nil, fmt.Errorf("Dyn API request failed: %s", dynRes.Messages) - } - - return &dynRes, nil +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } // 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} + payload := &creds{Customer: d.config.CustomerName, User: d.config.UserName, Pass: d.config.Password} dynRes, err := d.sendRequest(http.MethodPost, "Session", payload) if err != nil { return err @@ -153,7 +198,7 @@ func (d *DNSProvider) logout() error { return nil } - url := fmt.Sprintf("%s/Session", dynBaseURL) + url := fmt.Sprintf("%s/Session", defaultBaseURL) req, err := http.NewRequest(http.MethodDelete, url, nil) if err != nil { return err @@ -161,14 +206,14 @@ func (d *DNSProvider) logout() error { req.Header.Set("Content-Type", "application/json") req.Header.Set("Auth-Token", d.token) - resp, err := d.client.Do(req) + resp, err := d.config.HTTPClient.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) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API request failed to delete session with HTTP status code %d", resp.StatusCode) } d.token = "" @@ -176,47 +221,7 @@ func (d *DNSProvider) logout() error { 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(http.MethodPost, 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 - } - - return d.logout() -} - 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) @@ -224,45 +229,50 @@ func (d *DNSProvider) publish(zone, notes string) error { return err } -// CleanUp removes the TXT record matching the specified parameters -func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - fqdn, _, _ := acme.DNS01Record(domain, keyAuth) +func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) (*dynResponse, error) { + url := fmt.Sprintf("%s/%s", defaultBaseURL, resource) - authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + body, err := json.Marshal(payload) if err != nil { - return err + return nil, err } - err = d.login() + req, err := http.NewRequest(method, url, bytes.NewReader(body)) if err != nil { - return err + return nil, err } - - resource := fmt.Sprintf("TXTRecord/%s/%s/", authZone, fqdn) - url := fmt.Sprintf("%s/%s", dynBaseURL, resource) - - req, err := http.NewRequest(http.MethodDelete, url, nil) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Auth-Token", d.token) + if len(d.token) > 0 { + req.Header.Set("Auth-Token", d.token) + } - resp, err := d.client.Do(req) + resp, err := d.config.HTTPClient.Do(req) if err != nil { - return err + return nil, err } - resp.Body.Close() + defer resp.Body.Close() - if resp.StatusCode != 200 { - return fmt.Errorf("Dyn API request failed to delete TXT record HTTP status code %d", resp.StatusCode) + if resp.StatusCode >= 500 { + return nil, fmt.Errorf("API request failed with HTTP status code %d", resp.StatusCode) } - err = d.publish(authZone, "Removed TXT record for ACME dns-01 challenge using lego client") + var dynRes dynResponse + err = json.NewDecoder(resp.Body).Decode(&dynRes) if err != nil { - return err + return nil, err } - return d.logout() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API request failed with HTTP status code %d: %s", resp.StatusCode, dynRes.Messages) + } else if resp.StatusCode == 307 { + // TODO add support for HTTP 307 response and long running jobs + return nil, fmt.Errorf("API request returned HTTP 307. This is currently unsupported") + } + + if dynRes.Status == "failure" { + // TODO add better error handling + return nil, fmt.Errorf("API request failed: %s", dynRes.Messages) + } + + return &dynRes, nil } diff --git a/providers/dns/exoscale/exoscale.go b/providers/dns/exoscale/exoscale.go index e619b166..72015d36 100644 --- a/providers/dns/exoscale/exoscale.go +++ b/providers/dns/exoscale/exoscale.go @@ -5,15 +5,43 @@ package exoscale import ( "errors" "fmt" + "net/http" "os" + "time" "github.com/exoscale/egoscale" "github.com/xenolf/lego/acme" "github.com/xenolf/lego/platform/config/env" ) +const defaultBaseURL = "https://api.exoscale.ch/dns" + +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIKey string + APISecret string + Endpoint string + HTTPClient *http.Client + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("EXOSCALE_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("EXOSCALE_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("EXOSCALE_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("EXOSCALE_HTTP_TIMEOUT", 0), + }, + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface. type DNSProvider struct { + config *Config client *egoscale.Client } @@ -22,32 +50,52 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("EXOSCALE_API_KEY", "EXOSCALE_API_SECRET") if err != nil { - return nil, fmt.Errorf("Exoscale: %v", err) + return nil, fmt.Errorf("exoscale: %v", err) } - endpoint := os.Getenv("EXOSCALE_ENDPOINT") - return NewDNSProviderClient(values["EXOSCALE_API_KEY"], values["EXOSCALE_API_SECRET"], endpoint) + config := NewDefaultConfig() + config.APIKey = values["EXOSCALE_API_KEY"] + config.APISecret = values["EXOSCALE_API_SECRET"] + config.Endpoint = os.Getenv("EXOSCALE_ENDPOINT") + + return NewDNSProviderConfig(config) } -// NewDNSProviderClient Uses the supplied parameters to return a DNSProvider instance -// configured for Exoscale. +// NewDNSProviderClient Uses the supplied parameters +// to return a DNSProvider instance configured for Exoscale. +// Deprecated func NewDNSProviderClient(key, secret, endpoint string) (*DNSProvider, error) { - if key == "" || secret == "" { - return nil, fmt.Errorf("Exoscale credentials missing") + config := NewDefaultConfig() + config.APIKey = key + config.APISecret = secret + config.Endpoint = endpoint + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Exoscale. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("the configuration of the DNS provider is nil") } - if endpoint == "" { - endpoint = "https://api.exoscale.ch/dns" + if config.APIKey == "" || config.APISecret == "" { + return nil, fmt.Errorf("exoscale: credentials missing") } - return &DNSProvider{ - client: egoscale.NewClient(endpoint, key, secret), - }, nil + if config.Endpoint == "" { + config.Endpoint = defaultBaseURL + } + + client := egoscale.NewClient(config.Endpoint, config.APIKey, config.APISecret) + client.HTTPClient = config.HTTPClient + + return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfil the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) zone, recordName, err := d.FindZoneAndRecordName(fqdn, domain) if err != nil { return err @@ -61,7 +109,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { if recordID == 0 { record := egoscale.DNSRecord{ Name: recordName, - TTL: ttl, + TTL: d.config.TTL, Content: value, RecordType: "TXT", } @@ -74,7 +122,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { record := egoscale.UpdateDNSRecord{ ID: recordID, Name: recordName, - TTL: ttl, + TTL: d.config.TTL, Content: value, RecordType: "TXT", } @@ -111,6 +159,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + // FindExistingRecordID Query Exoscale to find an existing record for this name. // Returns nil if no record could be found func (d *DNSProvider) FindExistingRecordID(zone, recordName string) (int64, error) { diff --git a/providers/dns/exoscale/exoscale_test.go b/providers/dns/exoscale/exoscale_test.go index 10eed34e..2c2f6748 100644 --- a/providers/dns/exoscale/exoscale_test.go +++ b/providers/dns/exoscale/exoscale_test.go @@ -34,7 +34,11 @@ func TestNewDNSProviderValid(t *testing.T) { os.Setenv("EXOSCALE_API_KEY", "") os.Setenv("EXOSCALE_API_SECRET", "") - _, err := NewDNSProviderClient("example@example.com", "123", "") + config := NewDefaultConfig() + config.APIKey = "example@example.com" + config.APISecret = "123" + + _, err := NewDNSProviderConfig(config) assert.NoError(t, err) } @@ -53,11 +57,15 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { defer restoreEnv() _, err := NewDNSProvider() - assert.EqualError(t, err, "Exoscale: some credentials information are missing: EXOSCALE_API_KEY,EXOSCALE_API_SECRET") + assert.EqualError(t, err, "exoscale: some credentials information are missing: EXOSCALE_API_KEY,EXOSCALE_API_SECRET") } func TestExtractRootRecordName(t *testing.T) { - provider, err := NewDNSProviderClient("example@example.com", "123", "") + config := NewDefaultConfig() + config.APIKey = "example@example.com" + config.APISecret = "123" + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) zone, recordName, err := provider.FindZoneAndRecordName("_acme-challenge.bar.com.", "bar.com") @@ -67,7 +75,11 @@ func TestExtractRootRecordName(t *testing.T) { } func TestExtractSubRecordName(t *testing.T) { - provider, err := NewDNSProviderClient("example@example.com", "123", "") + config := NewDefaultConfig() + config.APIKey = "example@example.com" + config.APISecret = "123" + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) zone, recordName, err := provider.FindZoneAndRecordName("_acme-challenge.foo.bar.com.", "foo.bar.com") @@ -81,7 +93,11 @@ func TestLiveExoscalePresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderClient(exoscaleAPIKey, exoscaleAPISecret, "") + config := NewDefaultConfig() + config.APIKey = exoscaleAPIKey + config.APISecret = exoscaleAPISecret + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.Present(exoscaleDomain, "", "123d==") @@ -99,7 +115,11 @@ func TestLiveExoscaleCleanUp(t *testing.T) { time.Sleep(time.Second * 1) - provider, err := NewDNSProviderClient(exoscaleAPIKey, exoscaleAPISecret, "") + config := NewDefaultConfig() + config.APIKey = exoscaleAPIKey + config.APISecret = exoscaleAPISecret + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.CleanUp(exoscaleDomain, "", "123d==") diff --git a/providers/dns/fastdns/fastdns.go b/providers/dns/fastdns/fastdns.go index 4bc79400..fb8a9133 100644 --- a/providers/dns/fastdns/fastdns.go +++ b/providers/dns/fastdns/fastdns.go @@ -1,8 +1,10 @@ package fastdns import ( + "errors" "fmt" "reflect" + "time" configdns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v1" "github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid" @@ -10,9 +12,26 @@ import ( "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + edgegrid.Config + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + PropagationTimeout: env.GetOrDefaultSecond("AKAMAI_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("AKAMAI_POLLING_INTERVAL", acme.DefaultPollingInterval), + TTL: env.GetOrDefaultInt("AKAMAI_TTL", 120), + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface. type DNSProvider struct { - config edgegrid.Config + config *Config } // NewDNSProvider uses the supplied environment variables to return a DNSProvider instance: @@ -20,24 +39,27 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("AKAMAI_HOST", "AKAMAI_CLIENT_TOKEN", "AKAMAI_CLIENT_SECRET", "AKAMAI_ACCESS_TOKEN") if err != nil { - return nil, fmt.Errorf("FastDNS: %v", err) + return nil, fmt.Errorf("fastdns: %v", err) } - return NewDNSProviderClient( - values["AKAMAI_HOST"], - values["AKAMAI_CLIENT_TOKEN"], - values["AKAMAI_CLIENT_SECRET"], - values["AKAMAI_ACCESS_TOKEN"], - ) + config := NewDefaultConfig() + config.Config = edgegrid.Config{ + Host: values["AKAMAI_HOST"], + ClientToken: values["AKAMAI_CLIENT_TOKEN"], + ClientSecret: values["AKAMAI_CLIENT_SECRET"], + AccessToken: values["AKAMAI_ACCESS_TOKEN"], + MaxBody: 131072, + } + + return NewDNSProviderConfig(config) } -// NewDNSProviderClient uses the supplied parameters to return a DNSProvider instance -// configured for FastDNS. +// NewDNSProviderClient uses the supplied parameters +// to return a DNSProvider instance configured for FastDNS. +// Deprecated func NewDNSProviderClient(host, clientToken, clientSecret, accessToken string) (*DNSProvider, error) { - if clientToken == "" || clientSecret == "" || accessToken == "" || host == "" { - return nil, fmt.Errorf("FastDNS credentials are missing") - } - config := edgegrid.Config{ + config := NewDefaultConfig() + config.Config = edgegrid.Config{ Host: host, ClientToken: clientToken, ClientSecret: clientSecret, @@ -45,29 +67,40 @@ func NewDNSProviderClient(host, clientToken, clientSecret, accessToken string) ( MaxBody: 131072, } - return &DNSProvider{ - config: config, - }, nil + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for FastDNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("fastdns: the configuration of the DNS provider is nil") + } + + if config.ClientToken == "" || config.ClientSecret == "" || config.AccessToken == "" || config.Host == "" { + return nil, fmt.Errorf("FastDNS credentials are missing") + } + + return &DNSProvider{config: config}, nil } // Present creates a TXT record to fullfil the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) zoneName, recordName, err := d.findZoneAndRecordName(fqdn, domain) if err != nil { - return err + return fmt.Errorf("fastdns: %v", err) } - configdns.Init(d.config) + configdns.Init(d.config.Config) zone, err := configdns.GetZone(zoneName) if err != nil { - return err + return fmt.Errorf("fastdns: %v", err) } record := configdns.NewTxtRecord() record.SetField("name", recordName) - record.SetField("ttl", ttl) + record.SetField("ttl", d.config.TTL) record.SetField("target", value) record.SetField("active", true) @@ -89,14 +122,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { fqdn, _, _ := acme.DNS01Record(domain, keyAuth) zoneName, recordName, err := d.findZoneAndRecordName(fqdn, domain) if err != nil { - return err + return fmt.Errorf("fastdns: %v", err) } - configdns.Init(d.config) + configdns.Init(d.config.Config) zone, err := configdns.GetZone(zoneName) if err != nil { - return err + return fmt.Errorf("fastdns: %v", err) } existingRecord := d.findExistingRecord(zone, recordName) @@ -104,7 +137,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { if existingRecord != nil { err := zone.RemoveRecord(existingRecord) if err != nil { - return err + return fmt.Errorf("fastdns: %v", err) } return zone.Save() } @@ -112,6 +145,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + func (d *DNSProvider) findZoneAndRecordName(fqdn, domain string) (string, string, error) { zone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) if err != nil { diff --git a/providers/dns/fastdns/fastdns_test.go b/providers/dns/fastdns/fastdns_test.go index ed2b9d7c..859af226 100644 --- a/providers/dns/fastdns/fastdns_test.go +++ b/providers/dns/fastdns/fastdns_test.go @@ -43,7 +43,13 @@ func TestNewDNSProviderValid(t *testing.T) { os.Setenv("AKAMAI_CLIENT_SECRET", "") os.Setenv("AKAMAI_ACCESS_TOKEN", "") - _, err := NewDNSProviderClient("somehost", "someclienttoken", "someclientsecret", "someaccesstoken") + config := NewDefaultConfig() + config.Host = "somehost" + config.ClientToken = "someclienttoken" + config.ClientSecret = "someclientsecret" + config.AccessToken = "someaccesstoken" + + _, err := NewDNSProviderConfig(config) assert.NoError(t, err) } @@ -66,7 +72,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { os.Setenv("AKAMAI_ACCESS_TOKEN", "") _, err := NewDNSProvider() - assert.EqualError(t, err, "FastDNS: some credentials information are missing: AKAMAI_HOST,AKAMAI_CLIENT_TOKEN,AKAMAI_CLIENT_SECRET,AKAMAI_ACCESS_TOKEN") + assert.EqualError(t, err, "fastdns: some credentials information are missing: AKAMAI_HOST,AKAMAI_CLIENT_TOKEN,AKAMAI_CLIENT_SECRET,AKAMAI_ACCESS_TOKEN") } func TestLiveFastdnsPresent(t *testing.T) { @@ -74,7 +80,13 @@ func TestLiveFastdnsPresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderClient(host, clientToken, clientSecret, accessToken) + config := NewDefaultConfig() + config.Host = host + config.ClientToken = clientToken + config.ClientSecret = clientSecret + config.AccessToken = accessToken + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.Present(testDomain, "", "123d==") @@ -86,7 +98,13 @@ func TestLiveFastdnsPresent(t *testing.T) { } func TestExtractRootRecordName(t *testing.T) { - provider, err := NewDNSProviderClient("somehost", "someclienttoken", "someclientsecret", "someaccesstoken") + config := NewDefaultConfig() + config.Host = "somehost" + config.ClientToken = "someclienttoken" + config.ClientSecret = "someclientsecret" + config.AccessToken = "someaccesstoken" + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) zone, recordName, err := provider.findZoneAndRecordName("_acme-challenge.bar.com.", "bar.com") @@ -96,7 +114,13 @@ func TestExtractRootRecordName(t *testing.T) { } func TestExtractSubRecordName(t *testing.T) { - provider, err := NewDNSProviderClient("somehost", "someclienttoken", "someclientsecret", "someaccesstoken") + config := NewDefaultConfig() + config.Host = "somehost" + config.ClientToken = "someclienttoken" + config.ClientSecret = "someclientsecret" + config.AccessToken = "someaccesstoken" + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) zone, recordName, err := provider.findZoneAndRecordName("_acme-challenge.foo.bar.com.", "foo.bar.com") @@ -112,7 +136,13 @@ func TestLiveFastdnsCleanUp(t *testing.T) { time.Sleep(time.Second * 1) - provider, err := NewDNSProviderClient(host, clientToken, clientSecret, accessToken) + config := NewDefaultConfig() + config.Host = host + config.ClientToken = clientToken + config.ClientSecret = clientSecret + config.AccessToken = accessToken + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.CleanUp(testDomain, "", "123d==") diff --git a/providers/dns/gandi/client.go b/providers/dns/gandi/client.go new file mode 100644 index 00000000..3af340ec --- /dev/null +++ b/providers/dns/gandi/client.go @@ -0,0 +1,94 @@ +package gandi + +import ( + "encoding/xml" + "fmt" +) + +// 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) +} diff --git a/providers/dns/gandi/gandi.go b/providers/dns/gandi/gandi.go index d7243009..1ac1a6fa 100644 --- a/providers/dns/gandi/gandi.go +++ b/providers/dns/gandi/gandi.go @@ -5,6 +5,7 @@ package gandi import ( "bytes" "encoding/xml" + "errors" "fmt" "io" "io/ioutil" @@ -20,15 +21,38 @@ import ( // 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 +const ( + // defaultBaseURL Gandi XML-RPC endpoint used by Present and CleanUp + defaultBaseURL = "https://rpc.gandi.net/xmlrpc/" + minTTL = 300 ) +// findZoneByFqdn determines the DNS zone of an fqdn. +// It is overridden during tests. +var findZoneByFqdn = acme.FindZoneByFqdn + +// Config is used to configure the creation of the DNSProvider +type Config struct { + BaseURL string + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("GANDI_TTL", minTTL), + PropagationTimeout: env.GetOrDefaultSecond("GANDI_PROPAGATION_TIMEOUT", 40*time.Minute), + PollingInterval: env.GetOrDefaultSecond("GANDI_POLLING_INTERVAL", 60*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("GANDI_HTTP_TIMEOUT", 60*time.Second), + }, + } +} + // inProgressInfo contains information about an in-progress challenge type inProgressInfo struct { zoneID int // zoneID of gandi zone to restore in CleanUp @@ -40,11 +64,10 @@ type inProgressInfo struct { // 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 - client *http.Client + config *Config } // NewDNSProvider returns a DNSProvider instance configured for Gandi. @@ -52,23 +75,43 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("GANDI_API_KEY") if err != nil { - return nil, fmt.Errorf("GandiDNS: %v", err) + return nil, fmt.Errorf("gandi: %v", err) } - return NewDNSProviderCredentials(values["GANDI_API_KEY"]) + config := NewDefaultConfig() + config.APIKey = values["GANDI_API_KEY"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for Gandi. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for Gandi. +// Deprecated func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { - if apiKey == "" { - return nil, fmt.Errorf("no Gandi API Key given") + config := NewDefaultConfig() + config.APIKey = apiKey + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Gandi. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("gandi: the configuration of the DNS provider is nil") } + + if config.APIKey == "" { + return nil, fmt.Errorf("gandi: no API Key given") + } + + if config.BaseURL == "" { + config.BaseURL = defaultBaseURL + } + return &DNSProvider{ - apiKey: apiKey, + config: config, inProgressFQDNs: make(map[string]inProgressInfo), inProgressAuthZones: make(map[string]struct{}), - client: &http.Client{Timeout: 60 * time.Second}, }, nil } @@ -76,27 +119,27 @@ func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { // 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 + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + if d.config.TTL < minTTL { + d.config.TTL = minTTL // 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) + return fmt.Errorf("gandi: findZoneByFqdn failure: %v", err) } zoneID, err := d.getZoneID(authZone) if err != nil { - return err + return fmt.Errorf("gandi: %v", 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) + return fmt.Errorf("gandi: unexpected authZone %s for fqdn %s", authZone, fqdn) } name := fqdn[:len(fqdn)-len("."+authZone)] @@ -106,16 +149,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { defer d.inProgressMu.Unlock() if _, ok := d.inProgressAuthZones[authZone]; ok { - return fmt.Errorf( - "Gandi DNS: challenge already in progress for authZone %s", - authZone) + return fmt.Errorf("gandi: 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)) + newZoneName := fmt.Sprintf("%s [ACME Challenge %s]", acme.UnFqdn(authZone), time.Now().Format(time.RFC822Z)) newZoneID, err := d.cloneZone(zoneID, newZoneName) if err != nil { @@ -124,22 +163,22 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { newZoneVersion, err := d.newZoneVersion(newZoneID) if err != nil { - return err + return fmt.Errorf("gandi: %v", err) } - err = d.addTXTRecord(newZoneID, newZoneVersion, name, value, ttl) + err = d.addTXTRecord(newZoneID, newZoneVersion, name, value, d.config.TTL) if err != nil { - return err + return fmt.Errorf("gandi: %v", err) } err = d.setZoneVersion(newZoneID, newZoneVersion) if err != nil { - return err + return fmt.Errorf("gandi: %v", err) } err = d.setZone(authZone, newZoneID) if err != nil { - return err + return fmt.Errorf("gandi: %v", err) } // save data necessary for CleanUp @@ -149,6 +188,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { authZone: authZone, } d.inProgressAuthZones[authZone] = struct{}{} + return nil } @@ -157,6 +197,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // 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() @@ -175,7 +216,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // perform API actions to restore old gandi zone for authZone err := d.setZone(authZone, zoneID) if err != nil { - return err + return fmt.Errorf("gandi: %v", err) } return d.deleteZone(newZoneID) @@ -185,109 +226,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // 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 (d *DNSProvider) httpPost(url string, bodyType string, body io.Reader) ([]byte, error) { - resp, err := d.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 + return d.config.PropagationTimeout, d.config.PollingInterval } // rpcCall makes an XML-RPC call to Gandi's RPC endpoint by @@ -298,12 +237,12 @@ func (d *DNSProvider) rpcCall(call *methodCall, resp response) error { // marshal b, err := xml.MarshalIndent(call, "", " ") if err != nil { - return fmt.Errorf("Gandi DNS: Marshal Error: %v", err) + return fmt.Errorf("marshal error: %v", err) } // post b = append([]byte(``+"\n"), b...) - respBody, err := d.httpPost(endpoint, "text/xml", bytes.NewReader(b)) + respBody, err := d.httpPost(d.config.BaseURL, "text/xml", bytes.NewReader(b)) if err != nil { return err } @@ -311,7 +250,7 @@ func (d *DNSProvider) rpcCall(call *methodCall, resp response) error { // unmarshal err = xml.Unmarshal(respBody, resp) if err != nil { - return fmt.Errorf("Gandi DNS: Unmarshal Error: %v", err) + return fmt.Errorf("unmarshal error: %v", err) } if resp.faultCode() != 0 { return rpcError{ @@ -327,7 +266,7 @@ func (d *DNSProvider) getZoneID(domain string) (int, error) { err := d.rpcCall(&methodCall{ MethodName: "domain.info", Params: []param{ - paramString{Value: d.apiKey}, + paramString{Value: d.config.APIKey}, paramString{Value: domain}, }, }, resp) @@ -343,8 +282,7 @@ func (d *DNSProvider) getZoneID(domain string) (int, error) { } if zoneID == 0 { - return 0, fmt.Errorf( - "Gandi DNS: Could not determine zone_id for %s", domain) + return 0, fmt.Errorf("could not determine zone_id for %s", domain) } return zoneID, nil } @@ -354,7 +292,7 @@ func (d *DNSProvider) cloneZone(zoneID int, name string) (int, error) { err := d.rpcCall(&methodCall{ MethodName: "domain.zone.clone", Params: []param{ - paramString{Value: d.apiKey}, + paramString{Value: d.config.APIKey}, paramInt{Value: zoneID}, paramInt{Value: 0}, paramStruct{ @@ -378,7 +316,7 @@ func (d *DNSProvider) cloneZone(zoneID int, name string) (int, error) { } if newZoneID == 0 { - return 0, fmt.Errorf("Gandi DNS: Could not determine cloned zone_id") + return 0, fmt.Errorf("could not determine cloned zone_id") } return newZoneID, nil } @@ -388,7 +326,7 @@ func (d *DNSProvider) newZoneVersion(zoneID int) (int, error) { err := d.rpcCall(&methodCall{ MethodName: "domain.zone.version.new", Params: []param{ - paramString{Value: d.apiKey}, + paramString{Value: d.config.APIKey}, paramInt{Value: zoneID}, }, }, resp) @@ -397,7 +335,7 @@ func (d *DNSProvider) newZoneVersion(zoneID int) (int, error) { } if resp.Value == 0 { - return 0, fmt.Errorf("Gandi DNS: Could not create new zone version") + return 0, fmt.Errorf("could not create new zone version") } return resp.Value, nil } @@ -407,7 +345,7 @@ func (d *DNSProvider) addTXTRecord(zoneID int, version int, name string, value s err := d.rpcCall(&methodCall{ MethodName: "domain.zone.record.add", Params: []param{ - paramString{Value: d.apiKey}, + paramString{Value: d.config.APIKey}, paramInt{Value: zoneID}, paramInt{Value: version}, paramStruct{ @@ -436,7 +374,7 @@ func (d *DNSProvider) setZoneVersion(zoneID int, version int) error { err := d.rpcCall(&methodCall{ MethodName: "domain.zone.version.set", Params: []param{ - paramString{Value: d.apiKey}, + paramString{Value: d.config.APIKey}, paramInt{Value: zoneID}, paramInt{Value: version}, }, @@ -446,7 +384,7 @@ func (d *DNSProvider) setZoneVersion(zoneID int, version int) error { } if !resp.Value { - return fmt.Errorf("Gandi DNS: could not set zone version") + return fmt.Errorf("could not set zone version") } return nil } @@ -456,7 +394,7 @@ func (d *DNSProvider) setZone(domain string, zoneID int) error { err := d.rpcCall(&methodCall{ MethodName: "domain.zone.set", Params: []param{ - paramString{Value: d.apiKey}, + paramString{Value: d.config.APIKey}, paramString{Value: domain}, paramInt{Value: zoneID}, }, @@ -473,8 +411,7 @@ func (d *DNSProvider) setZone(domain string, zoneID int) error { } if respZoneID != zoneID { - return fmt.Errorf( - "Gandi DNS: Could not set new zone_id for %s", domain) + return fmt.Errorf("could not set new zone_id for %s", domain) } return nil } @@ -484,7 +421,7 @@ func (d *DNSProvider) deleteZone(zoneID int) error { err := d.rpcCall(&methodCall{ MethodName: "domain.zone.delete", Params: []param{ - paramString{Value: d.apiKey}, + paramString{Value: d.config.APIKey}, paramInt{Value: zoneID}, }, }, resp) @@ -493,7 +430,22 @@ func (d *DNSProvider) deleteZone(zoneID int) error { } if !resp.Value { - return fmt.Errorf("Gandi DNS: could not delete zone_id") + return fmt.Errorf("could not delete zone_id") } return nil } + +func (d *DNSProvider) httpPost(url string, bodyType string, body io.Reader) ([]byte, error) { + resp, err := d.config.HTTPClient.Post(url, bodyType, body) + if err != nil { + return nil, fmt.Errorf("HTTP Post Error: %v", err) + } + defer resp.Body.Close() + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("HTTP Post Error: %v", err) + } + + return b, nil +} diff --git a/providers/dns/gandi/gandi_test.go b/providers/dns/gandi/gandi_test.go index 792949b5..1d75a01d 100644 --- a/providers/dns/gandi/gandi_test.go +++ b/providers/dns/gandi/gandi_test.go @@ -15,12 +15,8 @@ import ( // 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) - require.NoError(t, err) - regexpDate, err := regexp.Compile(`\[ACME Challenge [^\]:]*:[^\]]*\]`) require.NoError(t, err) @@ -45,13 +41,19 @@ func TestDNSProvider(t *testing.T) { return "example.com.", nil } - // override gandi endpoint and findZoneByFqdn function - savedEndpoint, savedFindZoneByFqdn := endpoint, findZoneByFqdn - defer func() { - endpoint, findZoneByFqdn = savedEndpoint, savedFindZoneByFqdn - }() + config := NewDefaultConfig() + config.BaseURL = fakeServer.URL + "/" + config.APIKey = "123412341234123412341234" - endpoint, findZoneByFqdn = fakeServer.URL+"/", fakeFindZoneByFqdn + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) + + // override findZoneByFqdn function + savedFindZoneByFqdn := findZoneByFqdn + defer func() { + findZoneByFqdn = savedFindZoneByFqdn + }() + findZoneByFqdn = fakeFindZoneByFqdn // run Present err = provider.Present("abc.def.example.com", "", fakeKeyAuth) diff --git a/providers/dns/gandiv5/client.go b/providers/dns/gandiv5/client.go new file mode 100644 index 00000000..0116fdef --- /dev/null +++ b/providers/dns/gandiv5/client.go @@ -0,0 +1,18 @@ +package gandiv5 + +// types for JSON method calls and parameters + +type addFieldRequest struct { + RRSetTTL int `json:"rrset_ttl"` + RRSetValues []string `json:"rrset_values"` +} + +type deleteFieldRequest struct { + Delete bool `json:"delete"` +} + +// types for JSON responses + +type responseStruct struct { + Message string `json:"message"` +} diff --git a/providers/dns/gandiv5/gandiv5.go b/providers/dns/gandiv5/gandiv5.go index dea0f5f4..054f71d5 100644 --- a/providers/dns/gandiv5/gandiv5.go +++ b/providers/dns/gandiv5/gandiv5.go @@ -5,6 +5,7 @@ package gandiv5 import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -18,30 +19,51 @@ import ( // Gandi API reference: http://doc.livedns.gandi.net/ -var ( - // endpoint is the Gandi API endpoint used by Present and - // CleanUp. It is overridden during tests. - endpoint = "https://dns.api.gandi.net/api/v5" - - // findZoneByFqdn determines the DNS zone of an fqdn. It is overridden - // during tests. - findZoneByFqdn = acme.FindZoneByFqdn +const ( + // defaultBaseURL endpoint is the Gandi API endpoint used by Present and CleanUp. + defaultBaseURL = "https://dns.api.gandi.net/api/v5" + minTTL = 300 ) +// findZoneByFqdn determines the DNS zone of an fqdn. +// It is overridden during tests. +var findZoneByFqdn = acme.FindZoneByFqdn + // inProgressInfo contains information about an in-progress challenge type inProgressInfo struct { fieldName string authZone string } +// Config is used to configure the creation of the DNSProvider +type Config struct { + BaseURL string + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("GANDIV5_TTL", minTTL), + PropagationTimeout: env.GetOrDefaultSecond("GANDIV5_PROPAGATION_TIMEOUT", 20*time.Minute), + PollingInterval: env.GetOrDefaultSecond("GANDIV5_POLLING_INTERVAL", 20*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("GANDIV5_HTTP_TIMEOUT", 10*time.Second), + }, + } +} + // DNSProvider is an implementation of the // acme.ChallengeProviderTimeout interface that uses Gandi's LiveDNS // API to manage TXT records for a domain. type DNSProvider struct { - apiKey string + config *Config inProgressFQDNs map[string]inProgressInfo inProgressMu sync.Mutex - client *http.Client } // NewDNSProvider returns a DNSProvider instance configured for Gandi. @@ -49,43 +71,63 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("GANDIV5_API_KEY") if err != nil { - return nil, fmt.Errorf("GandiDNS: %v", err) + return nil, fmt.Errorf("gandi: %v", err) } - return NewDNSProviderCredentials(values["GANDIV5_API_KEY"]) + config := NewDefaultConfig() + config.APIKey = values["GANDIV5_API_KEY"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for Gandi. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for Gandi. +// Deprecated func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { - if apiKey == "" { - return nil, fmt.Errorf("Gandi DNS: No Gandi API Key given") + config := NewDefaultConfig() + config.APIKey = apiKey + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Gandi. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("gandiv5: the configuration of the DNS provider is nil") } + + if config.APIKey == "" { + return nil, fmt.Errorf("gandiv5: no API Key given") + } + + if config.BaseURL == "" { + config.BaseURL = defaultBaseURL + } + return &DNSProvider{ - apiKey: apiKey, + config: config, inProgressFQDNs: make(map[string]inProgressInfo), - client: &http.Client{Timeout: 10 * time.Second}, }, 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) - if ttl < 300 { - ttl = 300 // 300 is gandi minimum value for ttl + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + if d.config.TTL < minTTL { + d.config.TTL = minTTL // 300 is gandi minimum value for ttl } // find authZone authZone, err := findZoneByFqdn(fqdn, acme.RecursiveNameservers) if err != nil { - return fmt.Errorf("Gandi DNS: findZoneByFqdn failure: %v", err) + return fmt.Errorf("gandiv5: findZoneByFqdn failure: %v", 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) + return fmt.Errorf("gandiv5: unexpected authZone %s for fqdn %s", authZone, fqdn) } name := fqdn[:len(fqdn)-len("."+authZone)] @@ -95,7 +137,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { defer d.inProgressMu.Unlock() // add TXT record into authZone - err = d.addTXTRecord(acme.UnFqdn(authZone), name, value, ttl) + err = d.addTXTRecord(acme.UnFqdn(authZone), name, value, d.config.TTL) if err != nil { return err } @@ -125,37 +167,47 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { delete(d.inProgressFQDNs, fqdn) // delete TXT record from authZone - return d.deleteTXTRecord(acme.UnFqdn(authZone), fieldName) + err := d.deleteTXTRecord(acme.UnFqdn(authZone), fieldName) + if err != nil { + return fmt.Errorf("gandiv5: %v", err) + } + return nil } // Timeout returns the values (20*time.Minute, 20*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 20 * time.Minute, 20 * time.Second + return d.config.PropagationTimeout, d.config.PollingInterval } -// types for JSON method calls and parameters +// functions to perform API actions -type addFieldRequest struct { - RRSetTTL int `json:"rrset_ttl"` - RRSetValues []string `json:"rrset_values"` +func (d *DNSProvider) addTXTRecord(domain string, name string, value string, ttl int) error { + target := fmt.Sprintf("domains/%s/records/%s/TXT", domain, name) + response, err := d.sendRequest(http.MethodPut, target, addFieldRequest{ + RRSetTTL: ttl, + RRSetValues: []string{value}, + }) + if response != nil { + log.Infof("gandiv5: %s", response.Message) + } + return err } -type deleteFieldRequest struct { - Delete bool `json:"delete"` +func (d *DNSProvider) deleteTXTRecord(domain string, name string) error { + target := fmt.Sprintf("domains/%s/records/%s/TXT", domain, name) + response, err := d.sendRequest(http.MethodDelete, target, deleteFieldRequest{ + Delete: true, + }) + if response != nil && response.Message == "" { + log.Infof("gandiv5: Zone record deleted") + } + return err } -// types for JSON responses - -type responseStruct struct { - Message string `json:"message"` -} - -// POSTing/Marshalling/Unmarshalling - func (d *DNSProvider) sendRequest(method string, resource string, payload interface{}) (*responseStruct, error) { - url := fmt.Sprintf("%s/%s", endpoint, resource) + url := fmt.Sprintf("%s/%s", d.config.BaseURL, resource) body, err := json.Marshal(payload) if err != nil { @@ -168,19 +220,20 @@ func (d *DNSProvider) sendRequest(method string, resource string, payload interf } req.Header.Set("Content-Type", "application/json") - if len(d.apiKey) > 0 { - req.Header.Set("X-Api-Key", d.apiKey) + if len(d.config.APIKey) > 0 { + req.Header.Set("X-Api-Key", d.config.APIKey) } - resp, err := d.client.Do(req) + resp, err := d.config.HTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { - return nil, fmt.Errorf("Gandi DNS: request failed with HTTP status code %d", resp.StatusCode) + return nil, fmt.Errorf("request failed with HTTP status code %d", resp.StatusCode) } + var response responseStruct err = json.NewDecoder(resp.Body).Decode(&response) if err != nil && method != http.MethodDelete { @@ -189,28 +242,3 @@ func (d *DNSProvider) sendRequest(method string, resource string, payload interf return &response, nil } - -// functions to perform API actions - -func (d *DNSProvider) addTXTRecord(domain string, name string, value string, ttl int) error { - target := fmt.Sprintf("domains/%s/records/%s/TXT", domain, name) - response, err := d.sendRequest(http.MethodPut, target, addFieldRequest{ - RRSetTTL: ttl, - RRSetValues: []string{value}, - }) - if response != nil { - log.Infof("Gandi DNS: %s", response.Message) - } - return err -} - -func (d *DNSProvider) deleteTXTRecord(domain string, name string) error { - target := fmt.Sprintf("domains/%s/records/%s/TXT", domain, name) - response, err := d.sendRequest(http.MethodDelete, target, deleteFieldRequest{ - Delete: true, - }) - if response != nil && response.Message == "" { - log.Infof("Gandi DNS: Zone record deleted") - } - return err -} diff --git a/providers/dns/gandiv5/gandiv5_test.go b/providers/dns/gandiv5/gandiv5_test.go index 4ccb0bc7..720b2a44 100644 --- a/providers/dns/gandiv5/gandiv5_test.go +++ b/providers/dns/gandiv5/gandiv5_test.go @@ -15,12 +15,8 @@ import ( // 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) - require.NoError(t, err) - regexpToken, err := regexp.Compile(`"rrset_values":\[".+"\]`) require.NoError(t, err) @@ -46,13 +42,19 @@ func TestDNSProvider(t *testing.T) { return "example.com.", nil } - // override gandi endpoint and findZoneByFqdn function - savedEndpoint, savedFindZoneByFqdn := endpoint, findZoneByFqdn - defer func() { - endpoint, findZoneByFqdn = savedEndpoint, savedFindZoneByFqdn - }() + config := NewDefaultConfig() + config.APIKey = "123412341234123412341234" + config.BaseURL = fakeServer.URL - endpoint, findZoneByFqdn = fakeServer.URL, fakeFindZoneByFqdn + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) + + // override findZoneByFqdn function + savedFindZoneByFqdn := findZoneByFqdn + defer func() { + findZoneByFqdn = savedFindZoneByFqdn + }() + findZoneByFqdn = fakeFindZoneByFqdn // run Present err = provider.Present("abc.def.example.com", "", fakeKeyAuth) diff --git a/providers/dns/gcloud/googlecloud.go b/providers/dns/gcloud/googlecloud.go index 0f169677..36e067a5 100644 --- a/providers/dns/gcloud/googlecloud.go +++ b/providers/dns/gcloud/googlecloud.go @@ -4,29 +4,47 @@ package gcloud import ( "encoding/json" + "errors" "fmt" "io/ioutil" + "net/http" "os" "time" "github.com/xenolf/lego/acme" - + "github.com/xenolf/lego/platform/config/env" "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 +// Config is used to configure the creation of the DNSProvider +type Config struct { + Project string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client } -// NewDNSProvider returns a DNSProvider instance configured for Google Cloud -// DNS. Project name must be passed in the environment variable: GCE_PROJECT. -// A Service Account file can be passed in the environment variable: -// GCE_SERVICE_ACCOUNT_FILE +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("GCE_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("GCE_PROPAGATION_TIMEOUT", 180*time.Second), + PollingInterval: env.GetOrDefaultSecond("GCE_POLLING_INTERVAL", 5*time.Second), + } +} + +// DNSProvider is an implementation of the DNSProvider interface. +type DNSProvider struct { + config *Config + client *dns.Service +} + +// NewDNSProvider returns a DNSProvider instance configured for Google Cloud DNS. +// Project name must be passed in the environment variable: GCE_PROJECT. +// A Service Account file can be passed in the environment variable: GCE_SERVICE_ACCOUNT_FILE func NewDNSProvider() (*DNSProvider, error) { if saFile, ok := os.LookupEnv("GCE_SERVICE_ACCOUNT_FILE"); ok { return NewDNSProviderServiceAccount(saFile) @@ -36,37 +54,35 @@ func NewDNSProvider() (*DNSProvider, error) { return NewDNSProviderCredentials(project) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for Google Cloud DNS. +// 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") + return nil, fmt.Errorf("googlecloud: 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) + return nil, fmt.Errorf("googlecloud: 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 + + config := NewDefaultConfig() + config.Project = project + config.HTTPClient = client + + return NewDNSProviderConfig(config) } -// NewDNSProviderServiceAccount uses the supplied service account JSON file to -// return a DNSProvider instance configured for Google Cloud DNS. +// NewDNSProviderServiceAccount uses the supplied service account JSON file +// to return a DNSProvider instance configured for Google Cloud DNS. func NewDNSProviderServiceAccount(saFile string) (*DNSProvider, error) { if saFile == "" { - return nil, fmt.Errorf("Google Cloud Service Account file missing") + return nil, fmt.Errorf("googlecloud: Service Account file missing") } dat, err := ioutil.ReadFile(saFile) if err != nil { - return nil, fmt.Errorf("unable to read Service Account file: %v", err) + return nil, fmt.Errorf("googlecloud: unable to read Service Account file: %v", err) } // read project id from service account file @@ -75,39 +91,50 @@ func NewDNSProviderServiceAccount(saFile string) (*DNSProvider, error) { } err = json.Unmarshal(dat, &datJSON) if err != nil || datJSON.ProjectID == "" { - return nil, fmt.Errorf("project ID not found in Google Cloud Service Account file") + return nil, fmt.Errorf("googlecloud: project ID not found in Google Cloud Service Account file") } project := datJSON.ProjectID conf, err := google.JWTConfigFromJSON(dat, dns.NdevClouddnsReadwriteScope) if err != nil { - return nil, fmt.Errorf("unable to acquire config: %v", err) + return nil, fmt.Errorf("googlecloud: unable to acquire config: %v", err) } client := conf.Client(context.Background()) - svc, err := dns.New(client) - if err != nil { - return nil, fmt.Errorf("unable to create Google Cloud DNS service: %v", err) + config := NewDefaultConfig() + config.Project = project + config.HTTPClient = client + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Google Cloud DNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("googlecloud: the configuration of the DNS provider is nil") } - return &DNSProvider{ - project: project, - client: svc, - }, nil + + svc, err := dns.New(config.HTTPClient) + if err != nil { + return nil, fmt.Errorf("googlecloud: unable to create Google Cloud DNS service: %v", err) + } + + return &DNSProvider{config: config, client: svc}, nil } // Present creates a TXT record to fulfil the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) zone, err := d.getHostedZone(domain) if err != nil { - return err + return fmt.Errorf("googlecloud: %v", err) } rec := &dns.ResourceRecordSet{ Name: fqdn, Rrdatas: []string{value}, - Ttl: int64(ttl), + Ttl: int64(d.config.TTL), Type: "TXT", } change := &dns.Change{ @@ -117,25 +144,25 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // Look for existing records. existing, err := d.findTxtRecords(zone, fqdn) if err != nil { - return err + return fmt.Errorf("googlecloud: %v", err) } if len(existing) > 0 { // Attempt to delete the existing records when adding our new one. change.Deletions = existing } - chg, err := d.client.Changes.Create(d.project, zone, change).Do() + chg, err := d.client.Changes.Create(d.config.Project, zone, change).Do() if err != nil { - return err + return fmt.Errorf("googlecloud: %v", err) } // wait for change to be acknowledged for chg.Status == "pending" { time.Sleep(time.Second) - chg, err = d.client.Changes.Get(d.project, zone, chg.Id).Do() + chg, err = d.client.Changes.Get(d.config.Project, zone, chg.Id).Do() if err != nil { - return err + return fmt.Errorf("googlecloud: %v", err) } } @@ -148,26 +175,26 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { zone, err := d.getHostedZone(domain) if err != nil { - return err + return fmt.Errorf("googlecloud: %v", err) } records, err := d.findTxtRecords(zone, fqdn) if err != nil { - return err + return fmt.Errorf("googlecloud: %v", err) } if len(records) == 0 { return nil } - _, err = d.client.Changes.Create(d.project, zone, &dns.Change{Deletions: records}).Do() - return err + _, err = d.client.Changes.Create(d.config.Project, zone, &dns.Change{Deletions: records}).Do() + return fmt.Errorf("googlecloud: %v", err) } // Timeout customizes the timeout values used by the ACME package for checking // DNS record validity. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return 180 * time.Second, 5 * time.Second + return d.config.PropagationTimeout, d.config.PollingInterval } // getHostedZone returns the managed-zone @@ -178,23 +205,22 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) { } zones, err := d.client.ManagedZones. - List(d.project). + List(d.config.Project). DnsName(authZone). Do() if err != nil { - return "", fmt.Errorf("GoogleCloud API call failed: %v", err) + return "", fmt.Errorf("API call failed: %v", err) } if len(zones.ManagedZones) == 0 { - return "", fmt.Errorf("no matching GoogleCloud domain found for domain %s", authZone) + return "", fmt.Errorf("no matching domain found for domain %s", authZone) } return zones.ManagedZones[0].Name, nil } func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*dns.ResourceRecordSet, error) { - - recs, err := d.client.ResourceRecordSets.List(d.project, zone).Name(fqdn).Type("TXT").Do() + recs, err := d.client.ResourceRecordSets.List(d.config.Project, zone).Name(fqdn).Type("TXT").Do() if err != nil { return nil, err } diff --git a/providers/dns/gcloud/googlecloud_test.go b/providers/dns/gcloud/googlecloud_test.go index 3be678cf..4a4aae57 100644 --- a/providers/dns/gcloud/googlecloud_test.go +++ b/providers/dns/gcloud/googlecloud_test.go @@ -60,7 +60,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { os.Setenv("GCE_PROJECT", "") _, err := NewDNSProvider() - assert.EqualError(t, err, "Google Cloud project name missing") + assert.EqualError(t, err, "googlecloud: project name missing") } func TestLiveGoogleCloudPresent(t *testing.T) { diff --git a/providers/dns/glesys/client.go b/providers/dns/glesys/client.go new file mode 100644 index 00000000..a7e8cf8e --- /dev/null +++ b/providers/dns/glesys/client.go @@ -0,0 +1,24 @@ +package glesys + +// types for JSON method calls, parameters, and responses + +type addRecordRequest struct { + DomainName string `json:"domainname"` + Host string `json:"host"` + Type string `json:"type"` + Data string `json:"data"` + TTL int `json:"ttl,omitempty"` +} + +type deleteRecordRequest struct { + RecordID int `json:"recordid"` +} + +type responseStruct struct { + Response struct { + Status struct { + Code int `json:"code"` + } `json:"status"` + Record deleteRecordRequest `json:"record"` + } `json:"response"` +} diff --git a/providers/dns/glesys/glesys.go b/providers/dns/glesys/glesys.go index d6b07196..78a0cff1 100644 --- a/providers/dns/glesys/glesys.go +++ b/providers/dns/glesys/glesys.go @@ -5,6 +5,7 @@ package glesys import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -18,64 +19,102 @@ import ( // GleSYS API reference: https://github.com/GleSYS/API/wiki/API-Documentation -// domainAPI is the GleSYS API endpoint used by Present and CleanUp. -const domainAPI = "https://api.glesys.com/domain" +const ( + // defaultBaseURL is the GleSYS API endpoint used by Present and CleanUp. + defaultBaseURL = "https://api.glesys.com/domain" + minTTL = 60 +) + +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIUser string + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("GLESYS_TTL", minTTL), + PropagationTimeout: env.GetOrDefaultSecond("GLESYS_PROPAGATION_TIMEOUT", 20*time.Minute), + PollingInterval: env.GetOrDefaultSecond("GLESYS_POLLING_INTERVAL", 20*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("GLESYS_HTTP_TIMEOUT", 10*time.Second), + }, + } +} // DNSProvider is an implementation of the // acme.ChallengeProviderTimeout interface that uses GleSYS // API to manage TXT records for a domain. type DNSProvider struct { - apiUser string - apiKey string + config *Config activeRecords map[string]int inProgressMu sync.Mutex - client *http.Client } // NewDNSProvider returns a DNSProvider instance configured for GleSYS. -// Credentials must be passed in the environment variables: GLESYS_API_USER -// and GLESYS_API_KEY. +// Credentials must be passed in the environment variables: +// GLESYS_API_USER and GLESYS_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("GLESYS_API_USER", "GLESYS_API_KEY") if err != nil { - return nil, fmt.Errorf("GleSYS DNS: %v", err) + return nil, fmt.Errorf("glesys: %v", err) } - return NewDNSProviderCredentials(values["GLESYS_API_USER"], values["GLESYS_API_KEY"]) + config := NewDefaultConfig() + config.APIUser = values["GLESYS_API_USER"] + config.APIKey = values["GLESYS_API_KEY"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for GleSYS. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for GleSYS. +// Deprecated func NewDNSProviderCredentials(apiUser string, apiKey string) (*DNSProvider, error) { - if apiUser == "" || apiKey == "" { - return nil, fmt.Errorf("GleSYS DNS: Incomplete credentials provided") + config := NewDefaultConfig() + config.APIUser = apiUser + config.APIKey = apiKey + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for GleSYS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("glesys: the configuration of the DNS provider is nil") + } + + if config.APIUser == "" || config.APIKey == "" { + return nil, fmt.Errorf("glesys: incomplete credentials provided") } return &DNSProvider{ - apiUser: apiUser, - apiKey: apiKey, activeRecords: make(map[string]int), - client: &http.Client{Timeout: 10 * time.Second}, }, 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) - if ttl < 60 { - ttl = 60 // 60 is GleSYS minimum value for ttl + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + if d.config.TTL < minTTL { + d.config.TTL = minTTL // 60 is GleSYS minimum value for ttl } // find authZone authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) if err != nil { - return fmt.Errorf("GleSYS DNS: findZoneByFqdn failure: %v", err) + return fmt.Errorf("glesys: findZoneByFqdn failure: %v", err) } // determine name of TXT record if !strings.HasSuffix( strings.ToLower(fqdn), strings.ToLower("."+authZone)) { - return fmt.Errorf( - "GleSYS DNS: unexpected authZone %s for fqdn %s", authZone, fqdn) + return fmt.Errorf("glesys: unexpected authZone %s for fqdn %s", authZone, fqdn) } name := fqdn[:len(fqdn)-len("."+authZone)] @@ -85,7 +124,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { defer d.inProgressMu.Unlock() // add TXT record into authZone - recordID, err := d.addTXTRecord(domain, acme.UnFqdn(authZone), name, value, ttl) + recordID, err := d.addTXTRecord(domain, acme.UnFqdn(authZone), name, value, d.config.TTL) if err != nil { return err } @@ -118,36 +157,13 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { // are used by the acme package as timeout and check interval values // when checking for DNS record propagation with GleSYS. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return 20 * time.Minute, 20 * time.Second -} - -// types for JSON method calls, parameters, and responses - -type addRecordRequest struct { - DomainName string `json:"domainname"` - Host string `json:"host"` - Type string `json:"type"` - Data string `json:"data"` - TTL int `json:"ttl,omitempty"` -} - -type deleteRecordRequest struct { - RecordID int `json:"recordid"` -} - -type responseStruct struct { - Response struct { - Status struct { - Code int `json:"code"` - } `json:"status"` - Record deleteRecordRequest `json:"record"` - } `json:"response"` + return d.config.PropagationTimeout, d.config.PollingInterval } // POSTing/Marshalling/Unmarshalling func (d *DNSProvider) sendRequest(method string, resource string, payload interface{}) (*responseStruct, error) { - url := fmt.Sprintf("%s/%s", domainAPI, resource) + url := fmt.Sprintf("%s/%s", defaultBaseURL, resource) body, err := json.Marshal(payload) if err != nil { @@ -160,16 +176,16 @@ func (d *DNSProvider) sendRequest(method string, resource string, payload interf } req.Header.Set("Content-Type", "application/json") - req.SetBasicAuth(d.apiUser, d.apiKey) + req.SetBasicAuth(d.config.APIUser, d.config.APIKey) - resp, err := d.client.Do(req) + resp, err := d.config.HTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { - return nil, fmt.Errorf("GleSYS DNS: request failed with HTTP status code %d", resp.StatusCode) + return nil, fmt.Errorf("request failed with HTTP status code %d", resp.StatusCode) } var response responseStruct @@ -190,7 +206,7 @@ func (d *DNSProvider) addTXTRecord(fqdn string, domain string, name string, valu }) if response != nil && response.Response.Status.Code == http.StatusOK { - log.Infof("[%s] GleSYS DNS: Successfully created record id %d", fqdn, response.Response.Record.RecordID) + log.Infof("[%s]: Successfully created record id %d", fqdn, response.Response.Record.RecordID) return response.Response.Record.RecordID, nil } return 0, err @@ -201,7 +217,7 @@ func (d *DNSProvider) deleteTXTRecord(fqdn string, recordid int) error { RecordID: recordid, }) if response != nil && response.Response.Status.Code == 200 { - log.Infof("[%s] GleSYS DNS: Successfully deleted record id %d", fqdn, recordid) + log.Infof("[%s]: Successfully deleted record id %d", fqdn, recordid) } return err } diff --git a/providers/dns/godaddy/godaddy.go b/providers/dns/godaddy/godaddy.go index c569adcb..7c5d48a6 100644 --- a/providers/dns/godaddy/godaddy.go +++ b/providers/dns/godaddy/godaddy.go @@ -4,6 +4,7 @@ package godaddy import ( "bytes" "encoding/json" + "errors" "fmt" "io" "io/ioutil" @@ -15,46 +16,83 @@ import ( "github.com/xenolf/lego/platform/config/env" ) -// GoDaddyAPIURL represents the API endpoint to call. -const apiURL = "https://api.godaddy.com" +const ( + // defaultBaseURL represents the API endpoint to call. + defaultBaseURL = "https://api.godaddy.com" + minTTL = 600 +) + +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIKey string + APISecret string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("GODADDY_TTL", minTTL), + PropagationTimeout: env.GetOrDefaultSecond("GODADDY_PROPAGATION_TIMEOUT", 120*time.Second), + PollingInterval: env.GetOrDefaultSecond("GODADDY_POLLING_INTERVAL", 2*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("GODADDY_HTTP_TIMEOUT", 30*time.Second), + }, + } +} // DNSProvider is an implementation of the acme.ChallengeProvider interface type DNSProvider struct { - apiKey string - apiSecret string - client *http.Client + config *Config } // NewDNSProvider returns a DNSProvider instance configured for godaddy. -// Credentials must be passed in the environment variables: GODADDY_API_KEY -// and GODADDY_API_SECRET. +// Credentials must be passed in the environment variables: +// GODADDY_API_KEY and GODADDY_API_SECRET. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("GODADDY_API_KEY", "GODADDY_API_SECRET") if err != nil { - return nil, fmt.Errorf("GoDaddy: %v", err) + return nil, fmt.Errorf("godaddy: %v", err) } - return NewDNSProviderCredentials(values["GODADDY_API_KEY"], values["GODADDY_API_SECRET"]) + config := NewDefaultConfig() + config.APIKey = values["GODADDY_API_KEY"] + config.APISecret = values["GODADDY_API_SECRET"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for godaddy. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for godaddy. +// Deprecated func NewDNSProviderCredentials(apiKey, apiSecret string) (*DNSProvider, error) { - if apiKey == "" || apiSecret == "" { - return nil, fmt.Errorf("GoDaddy credentials missing") + config := NewDefaultConfig() + config.APIKey = apiKey + config.APISecret = apiSecret + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for godaddy. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("godaddy: the configuration of the DNS provider is nil") } - return &DNSProvider{ - apiKey: apiKey, - apiSecret: apiSecret, - client: &http.Client{Timeout: 30 * time.Second}, - }, nil + if config.APIKey == "" || config.APISecret == "" { + return nil, fmt.Errorf("godaddy: credentials missing") + } + + return &DNSProvider{config: config}, nil } // Timeout returns the timeout and interval to use when checking for DNS // propagation. Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return 120 * time.Second, 2 * time.Second + return d.config.PropagationTimeout, d.config.PollingInterval } func (d *DNSProvider) extractRecordName(fqdn, domain string) string { @@ -67,14 +105,14 @@ func (d *DNSProvider) extractRecordName(fqdn, domain string) string { // Present creates a TXT record to fulfil the dns-01 challenge func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) domainZone, err := d.getZone(fqdn) if err != nil { return err } - if ttl < 600 { - ttl = 600 + if d.config.TTL < minTTL { + d.config.TTL = minTTL } recordName := d.extractRecordName(fqdn, domainZone) @@ -83,7 +121,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { Type: "TXT", Name: recordName, Data: value, - TTL: ttl, + TTL: d.config.TTL, }, } @@ -141,16 +179,16 @@ func (d *DNSProvider) getZone(fqdn string) (string, error) { } func (d *DNSProvider) makeRequest(method, uri string, body io.Reader) (*http.Response, error) { - req, err := http.NewRequest(method, fmt.Sprintf("%s%s", apiURL, uri), body) + req, err := http.NewRequest(method, fmt.Sprintf("%s%s", defaultBaseURL, uri), body) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("sso-key %s:%s", d.apiKey, d.apiSecret)) + req.Header.Set("Authorization", fmt.Sprintf("sso-key %s:%s", d.config.APIKey, d.config.APISecret)) - return d.client.Do(req) + return d.config.HTTPClient.Do(req) } // DNSRecord a DNS record diff --git a/providers/dns/iij/iij.go b/providers/dns/iij/iij.go index ea9e5778..028e335d 100644 --- a/providers/dns/iij/iij.go +++ b/providers/dns/iij/iij.go @@ -3,6 +3,7 @@ package iij import ( "fmt" + "strconv" "strings" "time" @@ -14,9 +15,21 @@ import ( // Config is used to configure the creation of the DNSProvider type Config struct { - AccessKey string - SecretKey string - DoServiceCode string + AccessKey string + SecretKey string + DoServiceCode string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("IIJ_TTL", 300), + PropagationTimeout: env.GetOrDefaultSecond("IIJ_PROPAGATION_TIMEOUT", 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond("IIJ_POLLING_INTERVAL", 4*time.Second), + } } // DNSProvider implements the acme.ChallengeProvider interface @@ -29,19 +42,24 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("IIJ_API_ACCESS_KEY", "IIJ_API_SECRET_KEY", "IIJ_DO_SERVICE_CODE") if err != nil { - return nil, fmt.Errorf("IIJ: %v", err) + return nil, fmt.Errorf("iij: %v", err) } - return NewDNSProviderConfig(&Config{ - AccessKey: values["IIJ_API_ACCESS_KEY"], - SecretKey: values["IIJ_API_SECRET_KEY"], - DoServiceCode: values["IIJ_DO_SERVICE_CODE"], - }) + config := NewDefaultConfig() + config.AccessKey = values["IIJ_API_ACCESS_KEY"] + config.SecretKey = values["IIJ_API_SECRET_KEY"] + config.DoServiceCode = values["IIJ_DO_SERVICE_CODE"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderConfig takes a given config ans returns a custom configured -// DNSProvider instance +// NewDNSProviderConfig takes a given config +// and returns a custom configured DNSProvider instance func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config.SecretKey == "" || config.AccessKey == "" || config.DoServiceCode == "" { + return nil, fmt.Errorf("iij: credentials missing") + } + return &DNSProvider{ api: doapi.NewAPI(config.AccessKey, config.SecretKey), config: config, @@ -49,24 +67,28 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } // Timeout returns the timeout and interval to use when checking for DNS propagation. -func (p *DNSProvider) Timeout() (timeout, interval time.Duration) { - return time.Minute * 2, time.Second * 4 +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record using the specified parameters -func (p *DNSProvider) Present(domain, token, keyAuth string) error { +func (d *DNSProvider) Present(domain, token, keyAuth string) error { _, value, _ := acme.DNS01Record(domain, keyAuth) - return p.addTxtRecord(domain, value) + + err := d.addTxtRecord(domain, value) + return fmt.Errorf("iij: %v", err) } // CleanUp removes the TXT record matching the specified parameters -func (p *DNSProvider) CleanUp(domain, token, keyAuth string) error { +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { _, value, _ := acme.DNS01Record(domain, keyAuth) - return p.deleteTxtRecord(domain, value) + + err := d.deleteTxtRecord(domain, value) + return fmt.Errorf("iij: %v", err) } -func (p *DNSProvider) addTxtRecord(domain, value string) error { - zones, err := p.listZones() +func (d *DNSProvider) addTxtRecord(domain, value string) error { + zones, err := d.listZones() if err != nil { return err } @@ -77,25 +99,25 @@ func (p *DNSProvider) addTxtRecord(domain, value string) error { } request := protocol.RecordAdd{ - DoServiceCode: p.config.DoServiceCode, + DoServiceCode: d.config.DoServiceCode, ZoneName: zone, Owner: owner, - TTL: "300", + TTL: strconv.Itoa(d.config.TTL), RecordType: "TXT", RData: value, } response := &protocol.RecordAddResponse{} - if err := doapi.Call(*p.api, request, response); err != nil { + if err := doapi.Call(*d.api, request, response); err != nil { return err } - return p.commit() + return d.commit() } -func (p *DNSProvider) deleteTxtRecord(domain, value string) error { - zones, err := p.listZones() +func (d *DNSProvider) deleteTxtRecord(domain, value string) error { + zones, err := d.listZones() if err != nil { return err } @@ -105,45 +127,45 @@ func (p *DNSProvider) deleteTxtRecord(domain, value string) error { return err } - id, err := p.findTxtRecord(owner, zone, value) + id, err := d.findTxtRecord(owner, zone, value) if err != nil { return err } request := protocol.RecordDelete{ - DoServiceCode: p.config.DoServiceCode, + DoServiceCode: d.config.DoServiceCode, ZoneName: zone, RecordID: id, } response := &protocol.RecordDeleteResponse{} - if err := doapi.Call(*p.api, request, response); err != nil { + if err := doapi.Call(*d.api, request, response); err != nil { return err } - return p.commit() + return d.commit() } -func (p *DNSProvider) commit() error { +func (d *DNSProvider) commit() error { request := protocol.Commit{ - DoServiceCode: p.config.DoServiceCode, + DoServiceCode: d.config.DoServiceCode, } response := &protocol.CommitResponse{} - return doapi.Call(*p.api, request, response) + return doapi.Call(*d.api, request, response) } -func (p *DNSProvider) findTxtRecord(owner, zone, value string) (string, error) { +func (d *DNSProvider) findTxtRecord(owner, zone, value string) (string, error) { request := protocol.RecordListGet{ - DoServiceCode: p.config.DoServiceCode, + DoServiceCode: d.config.DoServiceCode, ZoneName: zone, } response := &protocol.RecordListGetResponse{} - if err := doapi.Call(*p.api, request, response); err != nil { + if err := doapi.Call(*d.api, request, response); err != nil { return "", err } @@ -162,14 +184,14 @@ func (p *DNSProvider) findTxtRecord(owner, zone, value string) (string, error) { return id, nil } -func (p *DNSProvider) listZones() ([]string, error) { +func (d *DNSProvider) listZones() ([]string, error) { request := protocol.ZoneListGet{ - DoServiceCode: p.config.DoServiceCode, + DoServiceCode: d.config.DoServiceCode, } response := &protocol.ZoneListGetResponse{} - if err := doapi.Call(*p.api, request, response); err != nil { + if err := doapi.Call(*d.api, request, response); err != nil { return nil, err } diff --git a/providers/dns/iij/iij_test.go b/providers/dns/iij/iij_test.go index dcc99cc3..1bcc76c8 100644 --- a/providers/dns/iij/iij_test.go +++ b/providers/dns/iij/iij_test.go @@ -95,7 +95,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { os.Setenv("IIJ_DO_SERVICE_CODE", "") _, err := NewDNSProvider() - assert.EqualError(t, err, "IIJ: some credentials information are missing: IIJ_API_ACCESS_KEY,IIJ_API_SECRET_KEY,IIJ_DO_SERVICE_CODE") + assert.EqualError(t, err, "iij: some credentials information are missing: IIJ_API_ACCESS_KEY,IIJ_API_SECRET_KEY,IIJ_DO_SERVICE_CODE") } func TestNewDNSProvider(t *testing.T) { diff --git a/providers/dns/lightsail/lightsail.go b/providers/dns/lightsail/lightsail.go index 6a3089a1..4c116dbb 100644 --- a/providers/dns/lightsail/lightsail.go +++ b/providers/dns/lightsail/lightsail.go @@ -3,6 +3,8 @@ package lightsail import ( + "errors" + "fmt" "math/rand" "os" "time" @@ -13,21 +15,15 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/lightsail" "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/platform/config/env" ) const ( maxRetries = 5 ) -// DNSProvider implements the acme.ChallengeProvider interface -type DNSProvider struct { - client *lightsail.Lightsail - dnsZone string -} - -// 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). +// 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 } @@ -47,13 +43,36 @@ func (c customRetryer) RetryRules(r *request.Request) time.Duration { return time.Duration(delay) * time.Millisecond } -// NewDNSProvider returns a DNSProvider instance configured for the AWS -// Lightsail service. +// Config is used to configure the creation of the DNSProvider +type Config struct { + DNSZone string + Region string + PropagationTimeout time.Duration + PollingInterval time.Duration +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + DNSZone: os.Getenv("DNS_ZONE"), + PropagationTimeout: env.GetOrDefaultSecond("LIGHTSAIL_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("LIGHTSAIL_POLLING_INTERVAL", acme.DefaultPollingInterval), + Region: env.GetOrDefaultString("LIGHTSAIL_REGION", "us-east-1"), + } +} + +// DNSProvider implements the acme.ChallengeProvider interface +type DNSProvider struct { + client *lightsail.Lightsail + config *Config +} + +// NewDNSProvider returns a DNSProvider instance configured for the AWS Lightsail 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_SESSION_TOKEN], [DNS_ZONE] +// [AWS_SESSION_TOKEN], [DNS_ZONE], [LIGHTSAIL_REGION] // 2. Shared credentials file (defaults to ~/.aws/credentials) // 3. Amazon EC2 IAM role // @@ -61,49 +80,70 @@ func (c customRetryer) RetryRules(r *request.Request) time.Duration { // // See also: https://github.com/aws/aws-sdk-go/wiki/configuring-sdk func NewDNSProvider() (*DNSProvider, error) { - r := customRetryer{} - r.NumMaxRetries = maxRetries + return NewDNSProviderConfig(NewDefaultConfig()) +} - config := aws.NewConfig().WithRegion("us-east-1") - sess, err := session.NewSession(request.WithRetryer(config, r)) +// NewDNSProviderConfig return a DNSProvider instance configured for AWS Lightsail. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("lightsail: the configuration of the DNS provider is nil") + } + + retryer := customRetryer{} + retryer.NumMaxRetries = maxRetries + + conf := aws.NewConfig().WithRegion(config.Region) + sess, err := session.NewSession(request.WithRetryer(conf, retryer)) if err != nil { return nil, err } return &DNSProvider{ - dnsZone: os.Getenv("DNS_ZONE"), - client: lightsail.New(sess), + config: config, + client: lightsail.New(sess), }, nil } // Present creates a TXT record using the specified parameters func (d *DNSProvider) Present(domain, token, keyAuth string) error { fqdn, value, _ := acme.DNS01Record(domain, keyAuth) - value = `"` + value + `"` - err := d.newTxtRecord(domain, fqdn, value) - return err + err := d.newTxtRecord(domain, fqdn, `"`+value+`"`) + if err != nil { + return fmt.Errorf("lightsail: %v", err) + } + return nil } // CleanUp removes the TXT record matching the specified parameters func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { fqdn, value, _ := acme.DNS01Record(domain, keyAuth) - value = `"` + value + `"` + params := &lightsail.DeleteDomainEntryInput{ - DomainName: aws.String(d.dnsZone), + DomainName: aws.String(d.config.DNSZone), DomainEntry: &lightsail.DomainEntry{ Name: aws.String(fqdn), Type: aws.String("TXT"), - Target: aws.String(value), + Target: aws.String(`"` + value + `"`), }, } + _, err := d.client.DeleteDomainEntry(params) - return err + if err != nil { + return fmt.Errorf("lightsail: %v", err) + } + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } func (d *DNSProvider) newTxtRecord(domain string, fqdn string, value string) error { params := &lightsail.CreateDomainEntryInput{ - DomainName: aws.String(d.dnsZone), + DomainName: aws.String(d.config.DNSZone), DomainEntry: &lightsail.DomainEntry{ Name: aws.String(fqdn), Target: aws.String(value), diff --git a/providers/dns/lightsail/lightsail_test.go b/providers/dns/lightsail/lightsail_test.go index a846ff3d..cf0ca84e 100644 --- a/providers/dns/lightsail/lightsail_test.go +++ b/providers/dns/lightsail/lightsail_test.go @@ -43,8 +43,10 @@ func makeLightsailProvider(ts *httptest.Server) (*DNSProvider, error) { return nil, err } + conf := NewDefaultConfig() + client := lightsail.New(sess) - return &DNSProvider{client: client}, nil + return &DNSProvider{client: client, config: conf}, nil } func TestCredentialsFromEnv(t *testing.T) { diff --git a/providers/dns/linode/linode.go b/providers/dns/linode/linode.go index 087d8b19..48739c12 100644 --- a/providers/dns/linode/linode.go +++ b/providers/dns/linode/linode.go @@ -19,6 +19,21 @@ const ( dnsUpdateFudgeSecs = 120 ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIKey string + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + PollingInterval: env.GetOrDefaultSecond("LINODE_POLLING_INTERVAL", 15*time.Second), + TTL: env.GetOrDefaultInt("LINODE_TTL", 60), + } +} + type hostedZoneInfo struct { domainID int resourceName string @@ -26,6 +41,7 @@ type hostedZoneInfo struct { // DNSProvider implements the acme.ChallengeProvider interface. type DNSProvider struct { + config *Config client *dns.DNS } @@ -34,27 +50,44 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("LINODE_API_KEY") if err != nil { - return nil, fmt.Errorf("Linode: %v", err) + return nil, fmt.Errorf("linode: %v", err) } - return NewDNSProviderCredentials(values["LINODE_API_KEY"]) + config := NewDefaultConfig() + config.APIKey = values["LINODE_API_KEY"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for Linode. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for Linode. +// Deprecated func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { - if len(apiKey) == 0 { - return nil, errors.New("Linode credentials missing") + config := NewDefaultConfig() + config.APIKey = apiKey + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Linode. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("linode: the configuration of the DNS provider is nil") + } + + if len(config.APIKey) == 0 { + return nil, errors.New("linode: credentials missing") } return &DNSProvider{ - client: dns.New(apiKey), + config: config, + client: dns.New(config.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) { +func (d *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 @@ -65,19 +98,19 @@ func (p *DNSProvider) Timeout() (timeout, interval time.Duration) { timeout = (time.Duration(minsRemaining) * time.Minute) + (dnsMinTTLSecs * time.Second) + (dnsUpdateFudgeSecs * time.Second) - interval = 15 * time.Second + interval = d.config.PollingInterval return } // Present creates a TXT record using the specified parameters. -func (p *DNSProvider) Present(domain, token, keyAuth string) error { +func (d *DNSProvider) Present(domain, token, keyAuth string) error { fqdn, value, _ := acme.DNS01Record(domain, keyAuth) - zone, err := p.getHostedZoneInfo(fqdn) + zone, err := d.getHostedZoneInfo(fqdn) if err != nil { return err } - if _, err = p.client.CreateDomainResourceTXT(zone.domainID, acme.UnFqdn(fqdn), value, 60); err != nil { + if _, err = d.client.CreateDomainResourceTXT(zone.domainID, acme.UnFqdn(fqdn), value, 60); err != nil { return err } @@ -85,15 +118,15 @@ func (p *DNSProvider) Present(domain, token, keyAuth string) error { } // CleanUp removes the TXT record matching the specified parameters. -func (p *DNSProvider) CleanUp(domain, token, keyAuth string) error { +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { fqdn, value, _ := acme.DNS01Record(domain, keyAuth) - zone, err := p.getHostedZoneInfo(fqdn) + zone, err := d.getHostedZoneInfo(fqdn) if err != nil { return err } // Get all TXT records for the specified domain. - resources, err := p.client.GetResourcesByType(zone.domainID, "TXT") + resources, err := d.client.GetResourcesByType(zone.domainID, "TXT") if err != nil { return err } @@ -101,7 +134,7 @@ func (p *DNSProvider) CleanUp(domain, token, keyAuth string) error { // Remove the specified resource, if it exists. for _, resource := range resources { if resource.Name == zone.resourceName && resource.Target == value { - resp, err := p.client.DeleteDomainResource(resource.DomainID, resource.ResourceID) + resp, err := d.client.DeleteDomainResource(resource.DomainID, resource.ResourceID) if err != nil { return err } @@ -115,16 +148,17 @@ func (p *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } -func (p *DNSProvider) getHostedZoneInfo(fqdn string) (*hostedZoneInfo, error) { +func (d *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.client.GetDomain(acme.UnFqdn(authZone)) + domain, err := d.client.GetDomain(acme.UnFqdn(authZone)) if err != nil { return nil, err } diff --git a/providers/dns/linode/linode_test.go b/providers/dns/linode/linode_test.go index 4fa287fb..78331f35 100644 --- a/providers/dns/linode/linode_test.go +++ b/providers/dns/linode/linode_test.go @@ -86,17 +86,22 @@ func TestNewDNSProviderWithoutEnv(t *testing.T) { os.Setenv("LINODE_API_KEY", "") _, err := NewDNSProvider() - assert.EqualError(t, err, "Linode: some credentials information are missing: LINODE_API_KEY") + assert.EqualError(t, err, "linode: some credentials information are missing: LINODE_API_KEY") } func TestNewDNSProviderCredentialsWithKey(t *testing.T) { - _, err := NewDNSProviderCredentials("testing") + config := NewDefaultConfig() + config.APIKey = "testing" + + _, err := NewDNSProviderConfig(config) assert.NoError(t, err) } func TestNewDNSProviderCredentialsWithoutKey(t *testing.T) { - _, err := NewDNSProviderCredentials("") - assert.EqualError(t, err, "Linode credentials missing") + config := NewDefaultConfig() + + _, err := NewDNSProviderConfig(config) + assert.EqualError(t, err, "linode: credentials missing") } func TestDNSProvider_Present(t *testing.T) { diff --git a/providers/dns/namecheap/client.go b/providers/dns/namecheap/client.go new file mode 100644 index 00000000..b816e1b4 --- /dev/null +++ b/providers/dns/namecheap/client.go @@ -0,0 +1,44 @@ +package namecheap + +import "encoding/xml" + +// 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"` +} + +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"` +} + +type getHostsResponse struct { + XMLName xml.Name `xml:"ApiResponse"` + Status string `xml:"Status,attr"` + Errors []apierror `xml:"Errors>Error"` + Hosts []host `xml:"CommandResponse>DomainDNSGetHostsResult>host"` +} + +type getTldsResponse struct { + XMLName xml.Name `xml:"ApiResponse"` + Errors []apierror `xml:"Errors>Error"` + Result []struct { + Name string `xml:",attr"` + } `xml:"CommandResponse>Tlds>Tld"` +} diff --git a/providers/dns/namecheap/namecheap.go b/providers/dns/namecheap/namecheap.go index f0ce56a8..c50cc628 100644 --- a/providers/dns/namecheap/namecheap.go +++ b/providers/dns/namecheap/namecheap.go @@ -4,10 +4,12 @@ package namecheap import ( "encoding/xml" + "errors" "fmt" "io/ioutil" "net/http" "net/url" + "strconv" "strings" "time" @@ -29,84 +31,175 @@ import ( // address as a form or query string value. This code uses a namecheap // service to query the client's IP address. -var ( - debug = false +const ( defaultBaseURL = "https://api.namecheap.com/xml.response" getIPURL = "https://dynamicdns.park-your-domain.com/getip" ) +// A challenge represents 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 +} + +// Config is used to configure the creation of the DNSProvider +type Config struct { + Debug bool + BaseURL string + APIUser string + APIKey string + ClientIP string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + BaseURL: defaultBaseURL, + Debug: env.GetOrDefaultBool("NAMECHEAP_DEBUG", false), + TTL: env.GetOrDefaultInt("NAMECHEAP_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("NAMECHEAP_PROPAGATION_TIMEOUT", 60*time.Minute), + PollingInterval: env.GetOrDefaultSecond("NAMECHEAP_POLLING_INTERVAL", 15*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("NAMECHEAP_HTTP_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 - client *http.Client + config *Config } // NewDNSProvider returns a DNSProvider instance configured for namecheap. -// Credentials must be passed in the environment variables: NAMECHEAP_API_USER -// and NAMECHEAP_API_KEY. +// Credentials must be passed in the environment variables: +// NAMECHEAP_API_USER and NAMECHEAP_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("NAMECHEAP_API_USER", "NAMECHEAP_API_KEY") if err != nil { - return nil, fmt.Errorf("NameCheap: %v", err) + return nil, fmt.Errorf("namecheap: %v", err) } - return NewDNSProviderCredentials(values["NAMECHEAP_API_USER"], values["NAMECHEAP_API_KEY"]) + config := NewDefaultConfig() + config.APIUser = values["NAMECHEAP_API_USER"] + config.APIKey = values["NAMECHEAP_API_KEY"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for namecheap. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for namecheap. +// Deprecated func NewDNSProviderCredentials(apiUser, apiKey string) (*DNSProvider, error) { - if apiUser == "" || apiKey == "" { - return nil, fmt.Errorf("Namecheap credentials missing") - } + config := NewDefaultConfig() + config.APIUser = apiUser + config.APIKey = apiKey - client := &http.Client{Timeout: 60 * time.Second} - - clientIP, err := getClientIP(client) - if err != nil { - return nil, err - } - - return &DNSProvider{ - baseURL: defaultBaseURL, - apiUser: apiUser, - apiKey: apiKey, - clientIP: clientIP, - client: client, - }, nil + return NewDNSProviderConfig(config) } -// 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. +// NewDNSProviderConfig return a DNSProvider instance configured for namecheap. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("namecheap: the configuration of the DNS provider is nil") + } + + if config.APIUser == "" || config.APIKey == "" { + return nil, fmt.Errorf("namecheap: credentials missing") + } + + if len(config.ClientIP) == 0 { + clientIP, err := getClientIP(config.HTTPClient, config.Debug) + if err != nil { + return nil, fmt.Errorf("namecheap: %v", err) + } + config.ClientIP = clientIP + } + + return &DNSProvider{config: config}, 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 + return d.config.PropagationTimeout, d.config.PollingInterval } -// 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"` +// 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 fmt.Errorf("namecheap: %v", err) + } + + ch, err := newChallenge(domain, keyAuth, tlds) + if err != nil { + return fmt.Errorf("namecheap: %v", err) + } + + hosts, err := d.getHosts(ch) + if err != nil { + return fmt.Errorf("namecheap: %v", err) + } + + d.addChallengeRecord(ch, &hosts) + + if d.config.Debug { + for _, h := range hosts { + log.Printf( + "%-5.5s %-30.30s %-6s %-70.70s\n", + h.Type, h.Name, h.TTL, h.Address) + } + } + + err = d.setHosts(ch, hosts) + if err != nil { + return fmt.Errorf("namecheap: %v", err) + } + return nil } -// apierror describes an error record in a namecheap API response. -type apierror struct { - Number int `xml:",attr"` - Description string `xml:",innerxml"` +// 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 fmt.Errorf("namecheap: %v", err) + } + + ch, err := newChallenge(domain, keyAuth, tlds) + if err != nil { + return fmt.Errorf("namecheap: %v", err) + } + + hosts, err := d.getHosts(ch) + if err != nil { + return fmt.Errorf("namecheap: %v", err) + } + + if removed := d.removeChallengeRecord(ch, &hosts); !removed { + return nil + } + + err = d.setHosts(ch, hosts) + if err != nil { + return fmt.Errorf("namecheap: %v", err) + } + return nil } -// getClientIP returns the client's public IP address. It uses namecheap's -// IP discovery service to perform the lookup. -func getClientIP(client *http.Client) (addr string, err error) { +// getClientIP returns the client's public IP address. +// It uses namecheap's IP discovery service to perform the lookup. +func getClientIP(client *http.Client, debug bool) (addr string, err error) { resp, err := client.Get(getIPURL) if err != nil { return "", err @@ -124,18 +217,6 @@ func getClientIP(client *http.Client) (addr string, err error) { return string(clientIP), nil } -// A challenge represents 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) { @@ -178,11 +259,11 @@ func newChallenge(domain, keyAuth string, tlds map[string]string) (*challenge, e // 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("ApiUser", d.config.APIUser) + v.Set("ApiKey", d.config.APIKey) + v.Set("UserName", d.config.APIUser) v.Set("Command", cmd) + v.Set("ClientIp", d.config.ClientIP) } // getTLDs requests the list of available TLDs from namecheap. @@ -190,10 +271,13 @@ 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, err := url.Parse(d.config.BaseURL) + if err != nil { + return nil, err + } reqURL.RawQuery = values.Encode() - resp, err := d.client.Get(reqURL.String()) + resp, err := d.config.HTTPClient.Get(reqURL.String()) if err != nil { return nil, err } @@ -208,21 +292,12 @@ func (d *DNSProvider) getTLDs() (tlds map[string]string, err error) { 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 + 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) + return nil, fmt.Errorf("%s [%d]", gtr.Errors[0].Description, gtr.Errors[0].Number) } tlds = make(map[string]string) @@ -236,13 +311,17 @@ func (d *DNSProvider) getTLDs() (tlds map[string]string, err error) { 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, err := url.Parse(d.config.BaseURL) + if err != nil { + return nil, err + } reqURL.RawQuery = values.Encode() - resp, err := d.client.Get(reqURL.String()) + resp, err := d.config.HTTPClient.Get(reqURL.String()) if err != nil { return nil, err } @@ -257,20 +336,12 @@ func (d *DNSProvider) getHosts(ch *challenge) (hosts []host, err error) { 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 + 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 nil, fmt.Errorf("%s [%d]", ghr.Errors[0].Description, ghr.Errors[0].Number) } return ghr.Hosts, nil @@ -280,6 +351,7 @@ func (d *DNSProvider) getHosts(ch *challenge) (hosts []host, err error) { 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) @@ -292,7 +364,7 @@ func (d *DNSProvider) setHosts(ch *challenge, hosts []host) error { values.Add("TTL"+ind, h.TTL) } - resp, err := d.client.PostForm(d.baseURL, values) + resp, err := d.config.HTTPClient.PostForm(d.config.BaseURL, values) if err != nil { return err } @@ -307,25 +379,15 @@ func (d *DNSProvider) setHosts(ch *challenge, hosts []host) error { 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 + 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) + return fmt.Errorf("%s [%d]", shr.Errors[0].Description, shr.Errors[0].Number) } if shr.Result.IsSuccess != "true" { - return fmt.Errorf("Namecheap setHosts failed") + return fmt.Errorf("setHosts failed") } return nil @@ -339,7 +401,7 @@ func (d *DNSProvider) addChallengeRecord(ch *challenge, hosts *[]host) { Type: "TXT", Address: ch.keyValue, MXPref: "10", - TTL: "120", + TTL: strconv.Itoa(d.config.TTL), } // If there's already a TXT record with the same name, replace it. @@ -367,57 +429,3 @@ func (d *DNSProvider) removeChallengeRecord(ch *challenge, hosts *[]host) bool { 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 { - log.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/providers/dns/namecheap/namecheap_test.go b/providers/dns/namecheap/namecheap_test.go index a213cf72..f089670a 100644 --- a/providers/dns/namecheap/namecheap_test.go +++ b/providers/dns/namecheap/namecheap_test.go @@ -7,6 +7,9 @@ import ( "net/url" "testing" "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var ( @@ -25,6 +28,174 @@ var ( } ) +func TestGetHosts(t *testing.T) { + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + mock := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + mockServer(&test, t, w, r) + })) + defer mock.Close() + + config := NewDefaultConfig() + config.BaseURL = mock.URL + config.APIUser = fakeUser + config.APIKey = fakeKey + config.ClientIP = fakeClientIP + config.HTTPClient = &http.Client{Timeout: 60 * time.Second} + + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) + + ch, _ := newChallenge(test.domain, "", tlds) + hosts, err := provider.getHosts(ch) + if test.errString != "" { + assert.EqualError(t, err, test.errString) + } else { + assert.NoError(t, err) + } + + next1: + for _, h := range hosts { + for _, th := range test.hosts { + if h == th { + continue next1 + } + } + t.Errorf("getHosts case %s unexpected record [%s:%s:%s]", test.name, h.Type, h.Name, h.Address) + } + + next2: + for _, th := range test.hosts { + for _, h := range hosts { + if h == th { + continue next2 + } + } + t.Errorf("getHosts case %s missing record [%s:%s:%s]", test.name, th.Type, th.Name, th.Address) + } + }) + } +} + +func TestSetHosts(t *testing.T) { + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + mock := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + mockServer(&test, t, w, r) + })) + defer mock.Close() + + prov := mockDNSProvider(mock.URL) + ch, _ := newChallenge(test.domain, "", tlds) + hosts, err := prov.getHosts(ch) + if test.errString != "" { + assert.EqualError(t, err, test.errString) + } else { + assert.NoError(t, err) + } + if err != nil { + return + } + + err = prov.setHosts(ch, hosts) + assert.NoError(t, err) + }) + } +} + +func TestPresent(t *testing.T) { + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + mock := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + mockServer(&test, t, w, r) + })) + defer mock.Close() + + prov := mockDNSProvider(mock.URL) + err := prov.Present(test.domain, "", "dummyKey") + if test.errString != "" { + assert.EqualError(t, err, "namecheap: "+test.errString) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCleanUp(t *testing.T) { + for _, test := range testcases { + t.Run(test.name, func(t *testing.T) { + mock := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + mockServer(&test, t, w, r) + })) + defer mock.Close() + + prov := mockDNSProvider(mock.URL) + err := prov.CleanUp(test.domain, "", "dummyKey") + if test.errString != "" { + assert.EqualError(t, err, "namecheap: "+test.errString) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestNamecheapDomainSplit(t *testing.T) { + tests := []struct { + domain string + valid bool + tld string + sld string + host string + }{ + {domain: "a.b.c.test.co.uk", valid: true, tld: "co.uk", sld: "test", host: "a.b.c"}, + {domain: "test.co.uk", valid: true, tld: "co.uk", sld: "test"}, + {domain: "test.com", valid: true, tld: "com", sld: "test"}, + {domain: "test.co.com", valid: true, tld: "co.com", sld: "test"}, + {domain: "www.test.com.au", valid: true, tld: "com.au", sld: "test", host: "www"}, + {domain: "www.za.com", valid: true, tld: "za.com", sld: "www"}, + {}, + {domain: "a"}, + {domain: "com"}, + {domain: "co.com"}, + {domain: "co.uk"}, + {domain: "test.au"}, + {domain: "za.com"}, + {domain: "www.za"}, + {domain: "www.test.au"}, + {domain: "www.test.unk"}, + } + + for _, test := range tests { + test := test + t.Run(test.domain, func(t *testing.T) { + 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) + } + }) + } +} + 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) @@ -79,193 +250,16 @@ func mockServer(tc *testcase, t *testing.T, w http.ResponseWriter, r *http.Reque } } -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, - client: &http.Client{Timeout: 60 * time.Second}, - } - - 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, - client: &http.Client{Timeout: 60 * time.Second}, - } -} + config := NewDefaultConfig() + config.BaseURL = url + config.APIUser = fakeUser + config.APIKey = fakeKey + config.ClientIP = fakeClientIP + config.HTTPClient = &http.Client{Timeout: 60 * time.Second} -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 { - test := test - t.Run(test.domain, func(t *testing.T) { - 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) - } - }) - } + provider, _ := NewDNSProviderConfig(config) + return provider } type testcase struct { @@ -279,38 +273,34 @@ type testcase struct { 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"}, + name: "Test:Success:1", + domain: "test.example.com", + hosts: []host{ + {Type: "A", Name: "home", Address: "10.0.0.1", MXPref: "10", TTL: "1799"}, + {Type: "A", Name: "www", Address: "10.0.0.2", MXPref: "10", TTL: "1200"}, + {Type: "AAAA", Name: "a", Address: "::0", MXPref: "10", TTL: "1799"}, + {Type: "CNAME", Name: "*", Address: "example.com.", MXPref: "10", TTL: "1799"}, + {Type: "MXE", Name: "example.com", Address: "10.0.0.5", MXPref: "10", TTL: "1800"}, + {Type: "URL", Name: "xyz", Address: "https://google.com", MXPref: "10", TTL: "1799"}, }, - "", - responseGetHostsSuccess1, - responseSetHostsSuccess1, + getHostsResponse: responseGetHostsSuccess1, + setHostsResponse: responseSetHostsSuccess1, }, { - "Test:Success:2", - "example.com", - []host{ - {"A", "@", "10.0.0.2", "10", "1200"}, - {"A", "www", "10.0.0.3", "10", "60"}, + name: "Test:Success:2", + domain: "example.com", + hosts: []host{ + {Type: "A", Name: "@", Address: "10.0.0.2", MXPref: "10", TTL: "1200"}, + {Type: "A", Name: "www", Address: "10.0.0.3", MXPref: "10", TTL: "60"}, }, - "", - responseGetHostsSuccess2, - responseSetHostsSuccess2, + getHostsResponse: responseGetHostsSuccess2, + setHostsResponse: responseSetHostsSuccess2, }, { - "Test:Error:BadApiKey:1", - "test.example.com", - nil, - "Namecheap error: API Key is invalid or API access has not been enabled [1011102]", - responseGetHostsErrorBadAPIKey1, - "", + name: "Test:Error:BadApiKey:1", + domain: "test.example.com", + errString: "API Key is invalid or API access has not been enabled [1011102]", + getHostsResponse: responseGetHostsErrorBadAPIKey1, }, } diff --git a/providers/dns/namedotcom/namedotcom.go b/providers/dns/namedotcom/namedotcom.go index 15272c5f..075f0183 100644 --- a/providers/dns/namedotcom/namedotcom.go +++ b/providers/dns/namedotcom/namedotcom.go @@ -3,66 +3,115 @@ package namedotcom import ( + "errors" "fmt" + "net/http" "os" "strings" + "time" "github.com/namedotcom/go/namecom" "github.com/xenolf/lego/acme" "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + Username string + APIToken string + Server string + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("NAMECOM_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("NAMECOM_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("NAMECOM_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("NAMECOM_HTTP_TIMEOUT", 10*time.Second), + }, + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface. type DNSProvider struct { client *namecom.NameCom + config *Config } // NewDNSProvider returns a DNSProvider instance configured for namedotcom. -// Credentials must be passed in the environment variables: NAMECOM_USERNAME and NAMECOM_API_TOKEN +// Credentials must be passed in the environment variables: +// NAMECOM_USERNAME and NAMECOM_API_TOKEN func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("NAMECOM_USERNAME", "NAMECOM_API_TOKEN") if err != nil { - return nil, fmt.Errorf("Name.com: %v", err) + return nil, fmt.Errorf("namedotcom: %v", err) } - server := os.Getenv("NAMECOM_SERVER") - return NewDNSProviderCredentials(values["NAMECOM_USERNAME"], values["NAMECOM_API_TOKEN"], server) + config := NewDefaultConfig() + config.Username = values["NAMECOM_USERNAME"] + config.APIToken = values["NAMECOM_API_TOKEN"] + config.Server = os.Getenv("NAMECOM_SERVER") + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for namedotcom. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for namedotcom. +// Deprecated func NewDNSProviderCredentials(username, apiToken, server string) (*DNSProvider, error) { - if username == "" { - return nil, fmt.Errorf("Name.com Username is required") - } - if apiToken == "" { - return nil, fmt.Errorf("Name.com API token is required") + config := NewDefaultConfig() + config.Username = username + config.APIToken = apiToken + config.Server = server + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for namedotcom. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("namedotcom: the configuration of the DNS provider is nil") } - client := namecom.New(username, apiToken) - - if server != "" { - client.Server = server + if config.Username == "" { + return nil, fmt.Errorf("namedotcom: username is required") } - return &DNSProvider{client: client}, nil + if config.APIToken == "" { + return nil, fmt.Errorf("namedotcom: API token is required") + } + + client := namecom.New(config.Username, config.APIToken) + client.Client = config.HTTPClient + + if config.Server != "" { + client.Server = config.Server + } + + return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfil the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) request := &namecom.Record{ DomainName: domain, Host: d.extractRecordName(fqdn, domain), Type: "TXT", - TTL: uint32(ttl), + TTL: uint32(d.config.TTL), Answer: value, } _, err := d.client.CreateRecord(request) if err != nil { - return fmt.Errorf("Name.com API call failed: %v", err) + return fmt.Errorf("namedotcom: API call failed: %v", err) } return nil @@ -74,7 +123,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { records, err := d.getRecords(domain) if err != nil { - return err + return fmt.Errorf("namedotcom: %v", err) } for _, rec := range records { @@ -85,7 +134,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } _, err := d.client.DeleteRecord(request) if err != nil { - return err + return fmt.Errorf("namedotcom: %v", err) } } } @@ -93,20 +142,21 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } -func (d *DNSProvider) getRecords(domain string) ([]*namecom.Record, error) { - var ( - err error - records []*namecom.Record - response *namecom.ListRecordsResponse - ) +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} +func (d *DNSProvider) getRecords(domain string) ([]*namecom.Record, error) { request := &namecom.ListRecordsRequest{ DomainName: domain, Page: 1, } + var records []*namecom.Record for request.Page > 0 { - response, err = d.client.ListRecords(request) + response, err := d.client.ListRecords(request) if err != nil { return nil, err } diff --git a/providers/dns/namedotcom/namedotcom_test.go b/providers/dns/namedotcom/namedotcom_test.go index 5153d6b5..88109ee4 100644 --- a/providers/dns/namedotcom/namedotcom_test.go +++ b/providers/dns/namedotcom/namedotcom_test.go @@ -32,7 +32,12 @@ func TestLiveNamedotcomPresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderCredentials(namedotcomUsername, namedotcomAPIToken, namedotcomServer) + config := NewDefaultConfig() + config.Username = namedotcomUsername + config.APIToken = namedotcomAPIToken + config.Server = namedotcomServer + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.Present(namedotcomDomain, "", "123d==") @@ -50,7 +55,12 @@ func TestLiveNamedotcomCleanUp(t *testing.T) { time.Sleep(time.Second * 1) - provider, err := NewDNSProviderCredentials(namedotcomUsername, namedotcomAPIToken, namedotcomServer) + config := NewDefaultConfig() + config.Username = namedotcomUsername + config.APIToken = namedotcomAPIToken + config.Server = namedotcomServer + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.CleanUp(namedotcomDomain, "", "123d==") diff --git a/providers/dns/netcup/client.go b/providers/dns/netcup/client.go index e498d694..f30bd7f1 100644 --- a/providers/dns/netcup/client.go +++ b/providers/dns/netcup/client.go @@ -6,12 +6,13 @@ import ( "fmt" "io/ioutil" "net/http" + "time" "github.com/xenolf/lego/acme" ) -// netcupBaseURL for reaching the jSON-based API-Endpoint of netcup -const netcupBaseURL = "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON" +// defaultBaseURL for reaching the jSON-based API-Endpoint of netcup +const defaultBaseURL = "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON" // success response status const success = "success" @@ -80,6 +81,7 @@ type DNSRecord struct { Destination string `json:"destination"` DeleteRecord bool `json:"deleterecord,omitempty"` State string `json:"state,omitempty"` + TTL int `json:"ttl,omitempty"` } // ResponseMsg as specified in netcup WSDL @@ -119,21 +121,20 @@ type Client struct { customerNumber string apiKey string apiPassword string - client *http.Client + HTTPClient *http.Client + BaseURL string } // NewClient creates a netcup DNS client -func NewClient(httpClient *http.Client, customerNumber string, apiKey string, apiPassword string) *Client { - client := http.DefaultClient - if httpClient != nil { - client = httpClient - } - +func NewClient(customerNumber string, apiKey string, apiPassword string) *Client { return &Client{ customerNumber: customerNumber, apiKey: apiKey, apiPassword: apiPassword, - client: client, + BaseURL: defaultBaseURL, + HTTPClient: &http.Client{ + Timeout: 10 * time.Second, + }, } } @@ -153,17 +154,17 @@ func (c *Client) Login() (string, error) { response, err := c.sendRequest(payload) if err != nil { - return "", fmt.Errorf("netcup: error sending request to DNS-API, %v", err) + return "", fmt.Errorf("error sending request to DNS-API, %v", err) } var r ResponseMsg err = json.Unmarshal(response, &r) if err != nil { - return "", fmt.Errorf("netcup: error decoding response of DNS-API, %v", err) + return "", fmt.Errorf("error decoding response of DNS-API, %v", err) } if r.Status != success { - return "", fmt.Errorf("netcup: error logging into DNS-API, %v", r.LongMessage) + return "", fmt.Errorf("error logging into DNS-API, %v", r.LongMessage) } return r.ResponseData.APISessionID, nil } @@ -183,18 +184,18 @@ func (c *Client) Logout(sessionID string) error { response, err := c.sendRequest(payload) if err != nil { - return fmt.Errorf("netcup: error logging out of DNS-API: %v", err) + return fmt.Errorf("error logging out of DNS-API: %v", err) } var r LogoutResponseMsg err = json.Unmarshal(response, &r) if err != nil { - return fmt.Errorf("netcup: error logging out of DNS-API: %v", err) + return fmt.Errorf("error logging out of DNS-API: %v", err) } if r.Status != success { - return fmt.Errorf("netcup: error logging out of DNS-API: %v", r.ShortMessage) + return fmt.Errorf("error logging out of DNS-API: %v", r.ShortMessage) } return nil } @@ -216,18 +217,18 @@ func (c *Client) UpdateDNSRecord(sessionID, domainName string, record DNSRecord) response, err := c.sendRequest(payload) if err != nil { - return fmt.Errorf("netcup: %v", err) + return err } var r ResponseMsg err = json.Unmarshal(response, &r) if err != nil { - return fmt.Errorf("netcup: %v", err) + return err } if r.Status != success { - return fmt.Errorf("netcup: %s: %+v", r.ShortMessage, r) + return fmt.Errorf("%s: %+v", r.ShortMessage, r) } return nil } @@ -249,18 +250,18 @@ func (c *Client) GetDNSRecords(hostname, apiSessionID string) ([]DNSRecord, erro response, err := c.sendRequest(payload) if err != nil { - return nil, fmt.Errorf("netcup: %v", err) + return nil, err } var r ResponseMsg err = json.Unmarshal(response, &r) if err != nil { - return nil, fmt.Errorf("netcup: %v", err) + return nil, err } if r.Status != success { - return nil, fmt.Errorf("netcup: %s", r.ShortMessage) + return nil, fmt.Errorf("%s", r.ShortMessage) } return r.ResponseData.DNSRecords, nil @@ -271,30 +272,30 @@ func (c *Client) GetDNSRecords(hostname, apiSessionID string) ([]DNSRecord, erro func (c *Client) sendRequest(payload interface{}) ([]byte, error) { body, err := json.Marshal(payload) if err != nil { - return nil, fmt.Errorf("netcup: %v", err) + return nil, err } - req, err := http.NewRequest(http.MethodPost, netcupBaseURL, bytes.NewReader(body)) + req, err := http.NewRequest(http.MethodPost, c.BaseURL, bytes.NewReader(body)) if err != nil { - return nil, fmt.Errorf("netcup: %v", err) + return nil, err } req.Close = true req.Header.Set("content-type", "application/json") req.Header.Set("User-Agent", acme.UserAgent) - resp, err := c.client.Do(req) + resp, err := c.HTTPClient.Do(req) if err != nil { - return nil, fmt.Errorf("netcup: %v", err) + return nil, err } if resp.StatusCode > 299 { - return nil, fmt.Errorf("netcup: API request failed with HTTP Status code %d", resp.StatusCode) + return nil, fmt.Errorf("API request failed with HTTP Status code %d", resp.StatusCode) } body, err = ioutil.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("netcup: read of response body failed, %v", err) + return nil, fmt.Errorf("read of response body failed, %v", err) } defer resp.Body.Close() @@ -310,11 +311,11 @@ func GetDNSRecordIdx(records []DNSRecord, record DNSRecord) (int, error) { return index, nil } } - return -1, fmt.Errorf("netcup: no DNS Record found") + return -1, fmt.Errorf("no DNS Record found") } // CreateTxtRecord uses the supplied values to return a DNSRecord of type TXT for the dns-01 challenge -func CreateTxtRecord(hostname, value string) DNSRecord { +func CreateTxtRecord(hostname, value string, ttl int) DNSRecord { return DNSRecord{ ID: 0, Hostname: hostname, @@ -323,5 +324,6 @@ func CreateTxtRecord(hostname, value string) DNSRecord { Destination: value, DeleteRecord: false, State: "", + TTL: ttl, } } diff --git a/providers/dns/netcup/client_test.go b/providers/dns/netcup/client_test.go index 6fd446c4..cba491b0 100644 --- a/providers/dns/netcup/client_test.go +++ b/providers/dns/netcup/client_test.go @@ -2,10 +2,8 @@ package netcup import ( "fmt" - "net/http" "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/xenolf/lego/acme" @@ -17,10 +15,7 @@ func TestClientAuth(t *testing.T) { } // Setup - httpClient := &http.Client{ - Timeout: 10 * time.Second, - } - client := NewClient(httpClient, testCustomerNumber, testAPIKey, testAPIPassword) + client := NewClient(testCustomerNumber, testAPIKey, testAPIPassword) for i := 1; i < 4; i++ { i := i @@ -42,10 +37,7 @@ func TestClientGetDnsRecords(t *testing.T) { t.Skip("skipping live test") } - httpClient := &http.Client{ - Timeout: 10 * time.Second, - } - client := NewClient(httpClient, testCustomerNumber, testAPIKey, testAPIPassword) + client := NewClient(testCustomerNumber, testAPIKey, testAPIPassword) // Setup sessionID, err := client.Login() @@ -73,10 +65,7 @@ func TestClientUpdateDnsRecord(t *testing.T) { } // Setup - httpClient := &http.Client{ - Timeout: 10 * time.Second, - } - client := NewClient(httpClient, testCustomerNumber, testAPIKey, testAPIPassword) + client := NewClient(testCustomerNumber, testAPIKey, testAPIPassword) sessionID, err := client.Login() assert.NoError(t, err) @@ -88,7 +77,7 @@ func TestClientUpdateDnsRecord(t *testing.T) { hostname := strings.Replace(fqdn, "."+zone, "", 1) - record := CreateTxtRecord(hostname, "asdf5678") + record := CreateTxtRecord(hostname, "asdf5678", 120) // test zone = acme.UnFqdn(zone) diff --git a/providers/dns/netcup/netcup.go b/providers/dns/netcup/netcup.go index e7cc4c6b..983b71e5 100644 --- a/providers/dns/netcup/netcup.go +++ b/providers/dns/netcup/netcup.go @@ -2,6 +2,7 @@ package netcup import ( + "errors" "fmt" "net/http" "strings" @@ -11,37 +12,78 @@ import ( "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + Key string + Password string + Customer string + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("NETCUP_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("NETCUP_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("NETCUP_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("NETCUP_HTTP_TIMEOUT", 10*time.Second), + }, + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface type DNSProvider struct { client *Client + config *Config } // NewDNSProvider returns a DNSProvider instance configured for netcup. -// Credentials must be passed in the environment variables: NETCUP_CUSTOMER_NUMBER, -// NETCUP_API_KEY, NETCUP_API_PASSWORD +// Credentials must be passed in the environment variables: +// NETCUP_CUSTOMER_NUMBER, NETCUP_API_KEY, NETCUP_API_PASSWORD func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("NETCUP_CUSTOMER_NUMBER", "NETCUP_API_KEY", "NETCUP_API_PASSWORD") if err != nil { return nil, fmt.Errorf("netcup: %v", err) } - return NewDNSProviderCredentials(values["NETCUP_CUSTOMER_NUMBER"], values["NETCUP_API_KEY"], values["NETCUP_API_PASSWORD"]) + config := NewDefaultConfig() + config.Customer = values["NETCUP_CUSTOMER_NUMBER"] + config.Key = values["NETCUP_API_KEY"] + config.Password = values["NETCUP_API_PASSWORD"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for netcup. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for netcup. +// Deprecated func NewDNSProviderCredentials(customer, key, password string) (*DNSProvider, error) { - if customer == "" || key == "" || password == "" { + config := NewDefaultConfig() + config.Customer = customer + config.Key = key + config.Password = password + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for netcup. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("netcup: the configuration of the DNS provider is nil") + } + + if config.Customer == "" || config.Key == "" || config.Password == "" { return nil, fmt.Errorf("netcup: netcup credentials missing") } - httpClient := &http.Client{ - Timeout: 10 * time.Second, - } + client := NewClient(config.Customer, config.Key, config.Password) + client.HTTPClient = config.HTTPClient - return &DNSProvider{ - client: NewClient(httpClient, customer, key, password), - }, nil + return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfill the dns-01 challenge @@ -55,21 +97,25 @@ func (d *DNSProvider) Present(domainName, token, keyAuth string) error { sessionID, err := d.client.Login() if err != nil { - return err + return fmt.Errorf("netcup: %v", err) } hostname := strings.Replace(fqdn, "."+zone, "", 1) - record := CreateTxtRecord(hostname, value) + record := CreateTxtRecord(hostname, value, d.config.TTL) err = d.client.UpdateDNSRecord(sessionID, acme.UnFqdn(zone), record) if err != nil { if errLogout := d.client.Logout(sessionID); errLogout != nil { - return fmt.Errorf("failed to add TXT-Record: %v; %v", err, errLogout) + return fmt.Errorf("netcup: failed to add TXT-Record: %v; %v", err, errLogout) } - return fmt.Errorf("failed to add TXT-Record: %v", err) + return fmt.Errorf("netcup: failed to add TXT-Record: %v", err) } - return d.client.Logout(sessionID) + err = d.client.Logout(sessionID) + if err != nil { + return fmt.Errorf("netcup: %v", err) + } + return nil } // CleanUp removes the TXT record matching the specified parameters @@ -78,12 +124,12 @@ func (d *DNSProvider) CleanUp(domainname, token, keyAuth string) error { zone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) if err != nil { - return fmt.Errorf("failed to find DNSZone, %v", err) + return fmt.Errorf("netcup: failed to find DNSZone, %v", err) } sessionID, err := d.client.Login() if err != nil { - return err + return fmt.Errorf("netcup: %v", err) } hostname := strings.Replace(fqdn, "."+zone, "", 1) @@ -92,14 +138,14 @@ func (d *DNSProvider) CleanUp(domainname, token, keyAuth string) error { records, err := d.client.GetDNSRecords(zone, sessionID) if err != nil { - return err + return fmt.Errorf("netcup: %v", err) } - record := CreateTxtRecord(hostname, value) + record := CreateTxtRecord(hostname, value, 0) idx, err := GetDNSRecordIdx(records, record) if err != nil { - return err + return fmt.Errorf("netcup: %v", err) } records[idx].DeleteRecord = true @@ -107,10 +153,20 @@ func (d *DNSProvider) CleanUp(domainname, token, keyAuth string) error { err = d.client.UpdateDNSRecord(sessionID, zone, records[idx]) if err != nil { if errLogout := d.client.Logout(sessionID); errLogout != nil { - return fmt.Errorf("%v; %v", err, errLogout) + return fmt.Errorf("netcup: %v; %v", err, errLogout) } - return err + return fmt.Errorf("netcup: %v", err) } - return d.client.Logout(sessionID) + err = d.client.Logout(sessionID) + if err != nil { + return fmt.Errorf("netcup: %v", err) + } + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } diff --git a/providers/dns/nifcloud/client.go b/providers/dns/nifcloud/client.go index 86b6fa65..dc10cbc2 100644 --- a/providers/dns/nifcloud/client.go +++ b/providers/dns/nifcloud/client.go @@ -15,9 +15,9 @@ import ( ) const ( - defaultEndpoint = "https://dns.api.cloud.nifty.com" - apiVersion = "2012-12-12N2013-12-16" - xmlNs = "https://route53.amazonaws.com/doc/2012-12-12/" + defaultBaseURL = "https://dns.api.cloud.nifty.com" + apiVersion = "2012-12-12N2013-12-16" + xmlNs = "https://route53.amazonaws.com/doc/2012-12-12/" ) // ChangeResourceRecordSetsRequest is a complex type that contains change information for the resource record set. @@ -88,31 +88,27 @@ type ChangeInfo struct { SubmittedAt string `xml:"SubmittedAt"` } -func newClient(httpClient *http.Client, accessKey string, secretKey string, endpoint string) *Client { - client := http.DefaultClient - if httpClient != nil { - client = httpClient - } - +// NewClient Creates a new client of NIFCLOUD DNS +func NewClient(accessKey string, secretKey string) *Client { return &Client{ - accessKey: accessKey, - secretKey: secretKey, - endpoint: endpoint, - client: client, + accessKey: accessKey, + secretKey: secretKey, + BaseURL: defaultBaseURL, + HTTPClient: &http.Client{}, } } // Client client of NIFCLOUD DNS type Client struct { - accessKey string - secretKey string - endpoint string - client *http.Client + accessKey string + secretKey string + BaseURL string + HTTPClient *http.Client } // ChangeResourceRecordSets Call ChangeResourceRecordSets API and return response. func (c *Client) ChangeResourceRecordSets(hostedZoneID string, input ChangeResourceRecordSetsRequest) (*ChangeResourceRecordSetsResponse, error) { - requestURL := fmt.Sprintf("%s/%s/hostedzone/%s/rrset", c.endpoint, apiVersion, hostedZoneID) + requestURL := fmt.Sprintf("%s/%s/hostedzone/%s/rrset", c.BaseURL, apiVersion, hostedZoneID) body := &bytes.Buffer{} body.Write([]byte(xml.Header)) @@ -133,7 +129,7 @@ func (c *Client) ChangeResourceRecordSets(hostedZoneID string, input ChangeResou return nil, fmt.Errorf("an error occurred during the creation of the signature: %v", err) } - res, err := c.client.Do(req) + res, err := c.HTTPClient.Do(req) if err != nil { return nil, err } @@ -164,7 +160,7 @@ func (c *Client) ChangeResourceRecordSets(hostedZoneID string, input ChangeResou // GetChange Call GetChange API and return response. func (c *Client) GetChange(statusID string) (*GetChangeResponse, error) { - requestURL := fmt.Sprintf("%s/%s/change/%s", c.endpoint, apiVersion, statusID) + requestURL := fmt.Sprintf("%s/%s/change/%s", c.BaseURL, apiVersion, statusID) req, err := http.NewRequest(http.MethodGet, requestURL, nil) if err != nil { @@ -176,7 +172,7 @@ func (c *Client) GetChange(statusID string) (*GetChangeResponse, error) { return nil, fmt.Errorf("an error occurred during the creation of the signature: %v", err) } - res, err := c.client.Do(req) + res, err := c.HTTPClient.Do(req) if err != nil { return nil, err } diff --git a/providers/dns/nifcloud/client_test.go b/providers/dns/nifcloud/client_test.go index cc8e69f0..e56ccd37 100644 --- a/providers/dns/nifcloud/client_test.go +++ b/providers/dns/nifcloud/client_test.go @@ -31,7 +31,8 @@ func TestChangeResourceRecordSets(t *testing.T) { server := runTestServer(responseBody, http.StatusOK) defer server.Close() - client := newClient(nil, "", "", server.URL) + client := NewClient("", "") + client.BaseURL = server.URL res, err := client.ChangeResourceRecordSets("example.com", ChangeResourceRecordSetsRequest{}) require.NoError(t, err) @@ -82,7 +83,8 @@ func TestChangeResourceRecordSetsErrors(t *testing.T) { server := runTestServer(test.responseBody, test.statusCode) defer server.Close() - client := newClient(nil, "", "", server.URL) + client := NewClient("", "") + client.BaseURL = server.URL res, err := client.ChangeResourceRecordSets("example.com", ChangeResourceRecordSetsRequest{}) assert.Nil(t, res) @@ -105,7 +107,8 @@ func TestGetChange(t *testing.T) { server := runTestServer(responseBody, http.StatusOK) defer server.Close() - client := newClient(nil, "", "", server.URL) + client := NewClient("", "") + client.BaseURL = server.URL res, err := client.GetChange("12345") require.NoError(t, err) @@ -156,7 +159,8 @@ func TestGetChangeErrors(t *testing.T) { server := runTestServer(test.responseBody, test.statusCode) defer server.Close() - client := newClient(nil, "", "", server.URL) + client := NewClient("", "") + client.BaseURL = server.URL res, err := client.GetChange("12345") assert.Nil(t, res) diff --git a/providers/dns/nifcloud/nifcloud.go b/providers/dns/nifcloud/nifcloud.go index 5a7fa6b8..7e828aa9 100644 --- a/providers/dns/nifcloud/nifcloud.go +++ b/providers/dns/nifcloud/nifcloud.go @@ -3,6 +3,7 @@ package nifcloud import ( + "errors" "fmt" "net/http" "os" @@ -12,49 +13,110 @@ import ( "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + BaseURL string + AccessKey string + SecretKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("NIFCLOUD_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("NIFCLOUD_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("NIFCLOUD_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("NIFCLOUD_HTTP_TIMEOUT", 30*time.Second), + }, + } +} + // DNSProvider implements the acme.ChallengeProvider interface type DNSProvider struct { client *Client + config *Config } // NewDNSProvider returns a DNSProvider instance configured for the NIFCLOUD DNS service. -// Credentials must be passed in the environment variables: NIFCLOUD_ACCESS_KEY_ID and NIFCLOUD_SECRET_ACCESS_KEY. +// Credentials must be passed in the environment variables: +// NIFCLOUD_ACCESS_KEY_ID and NIFCLOUD_SECRET_ACCESS_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("NIFCLOUD_ACCESS_KEY_ID", "NIFCLOUD_SECRET_ACCESS_KEY") if err != nil { - return nil, fmt.Errorf("NIFCLOUD: %v", err) + return nil, fmt.Errorf("nifcloud: %v", err) } - endpoint := os.Getenv("NIFCLOUD_DNS_ENDPOINT") - if endpoint == "" { - endpoint = defaultEndpoint - } + config := NewDefaultConfig() + config.BaseURL = os.Getenv("NIFCLOUD_DNS_ENDPOINT") + config.AccessKey = values["NIFCLOUD_ACCESS_KEY_ID"] + config.SecretKey = values["NIFCLOUD_SECRET_ACCESS_KEY"] - httpClient := &http.Client{Timeout: 30 * time.Second} - - return NewDNSProviderCredentials(httpClient, endpoint, values["NIFCLOUD_ACCESS_KEY_ID"], values["NIFCLOUD_SECRET_ACCESS_KEY"]) + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for NIFCLOUD. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for NIFCLOUD. +// Deprecated func NewDNSProviderCredentials(httpClient *http.Client, endpoint, accessKey, secretKey string) (*DNSProvider, error) { - client := newClient(httpClient, accessKey, secretKey, endpoint) + config := NewDefaultConfig() + config.HTTPClient = httpClient + config.BaseURL = endpoint + config.AccessKey = accessKey + config.SecretKey = secretKey - return &DNSProvider{ - client: client, - }, nil + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for NIFCLOUD. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("nifcloud: the configuration of the DNS provider is nil") + } + + client := NewClient(config.AccessKey, config.SecretKey) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + if len(config.BaseURL) > 0 { + client.BaseURL = config.BaseURL + } + + return &DNSProvider{client: client, config: config}, 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) - return d.changeRecord("CREATE", fqdn, value, domain, ttl) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + err := d.changeRecord("CREATE", fqdn, value, domain, d.config.TTL) + if err != nil { + return fmt.Errorf("nifcloud: %v", err) + } + return err } // CleanUp removes the TXT record matching the specified parameters func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) - return d.changeRecord("DELETE", fqdn, value, domain, ttl) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + err := d.changeRecord("DELETE", fqdn, value, domain, d.config.TTL) + if err != nil { + return fmt.Errorf("nifcloud: %v", err) + } + return err +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } func (d *DNSProvider) changeRecord(action, fqdn, value, domain string, ttl int) error { diff --git a/providers/dns/ns1/ns1.go b/providers/dns/ns1/ns1.go index 148747bd..40dff6c6 100644 --- a/providers/dns/ns1/ns1.go +++ b/providers/dns/ns1/ns1.go @@ -3,6 +3,7 @@ package ns1 import ( + "errors" "fmt" "net/http" "strings" @@ -14,9 +15,31 @@ import ( "gopkg.in/ns1/ns1-go.v2/rest/model/dns" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("NS1_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("NS1_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("NS1_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("NS1_HTTP_TIMEOUT", 10*time.Second), + }, + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface. type DNSProvider struct { client *rest.Client + config *Config } // NewDNSProvider returns a DNSProvider instance configured for NS1. @@ -24,38 +47,53 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("NS1_API_KEY") if err != nil { - return nil, fmt.Errorf("NS1: %v", err) + return nil, fmt.Errorf("ns1: %v", err) } - return NewDNSProviderCredentials(values["NS1_API_KEY"]) + config := NewDefaultConfig() + config.APIKey = values["NS1_API_KEY"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for NS1. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for NS1. +// Deprecated func NewDNSProviderCredentials(key string) (*DNSProvider, error) { - if key == "" { - return nil, fmt.Errorf("NS1 credentials missing") + config := NewDefaultConfig() + config.APIKey = key + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for NS1. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("ns1: the configuration of the DNS provider is nil") } - httpClient := &http.Client{Timeout: time.Second * 10} - client := rest.NewClient(httpClient, rest.SetAPIKey(key)) + if config.APIKey == "" { + return nil, fmt.Errorf("ns1: credentials missing") + } - return &DNSProvider{client}, nil + client := rest.NewClient(config.HTTPClient, rest.SetAPIKey(config.APIKey)) + + return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfil the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) zone, err := d.getHostedZone(domain) if err != nil { - return err + return fmt.Errorf("ns1: %v", err) } - record := d.newTxtRecord(zone, fqdn, value, ttl) + record := d.newTxtRecord(zone, fqdn, value, d.config.TTL) _, err = d.client.Records.Create(record) if err != nil && err != rest.ErrRecordExists { - return err + return fmt.Errorf("ns1: %v", err) } return nil @@ -67,23 +105,29 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { zone, err := d.getHostedZone(domain) if err != nil { - return err + return fmt.Errorf("ns1: %v", err) } name := acme.UnFqdn(fqdn) _, err = d.client.Records.Delete(zone.Zone, name, "TXT") - return err + return fmt.Errorf("ns1: %v", err) +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } func (d *DNSProvider) getHostedZone(domain string) (*dns.Zone, error) { authZone, err := getAuthZone(domain) if err != nil { - return nil, err + return nil, fmt.Errorf("ns1: %v", err) } zone, _, err := d.client.Zones.Get(authZone) if err != nil { - return nil, err + return nil, fmt.Errorf("ns1: %v", err) } return zone, nil diff --git a/providers/dns/ns1/ns1_test.go b/providers/dns/ns1/ns1_test.go index b9866fb7..f370e74a 100644 --- a/providers/dns/ns1/ns1_test.go +++ b/providers/dns/ns1/ns1_test.go @@ -30,7 +30,10 @@ func TestNewDNSProviderValid(t *testing.T) { defer restoreEnv() os.Setenv("NS1_API_KEY", "") - _, err := NewDNSProviderCredentials("123") + config := NewDefaultConfig() + config.APIKey = "123" + + _, err := NewDNSProviderConfig(config) assert.NoError(t, err) } @@ -39,7 +42,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { os.Setenv("NS1_API_KEY", "") _, err := NewDNSProvider() - assert.EqualError(t, err, "NS1: some credentials information are missing: NS1_API_KEY") + assert.EqualError(t, err, "ns1: some credentials information are missing: NS1_API_KEY") } func TestLivePresent(t *testing.T) { @@ -47,7 +50,10 @@ func TestLivePresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderCredentials(apiKey) + config := NewDefaultConfig() + config.APIKey = apiKey + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.Present(domain, "", "123d==") @@ -61,7 +67,10 @@ func TestLiveCleanUp(t *testing.T) { time.Sleep(time.Second * 1) - provider, err := NewDNSProviderCredentials(apiKey) + config := NewDefaultConfig() + config.APIKey = apiKey + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.CleanUp(domain, "", "123d==") diff --git a/providers/dns/otc/client.go b/providers/dns/otc/client.go new file mode 100644 index 00000000..7f4fd34b --- /dev/null +++ b/providers/dns/otc/client.go @@ -0,0 +1,68 @@ +package otc + +type recordset struct { + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + TTL int `json:"ttl"` + Records []string `json:"records"` +} + +type nameResponse struct { + Name string `json:"name"` +} + +type userResponse struct { + Name string `json:"name"` + Password string `json:"password"` + Domain nameResponse `json:"domain"` +} + +type passwordResponse struct { + User userResponse `json:"user"` +} + +type identityResponse struct { + Methods []string `json:"methods"` + Password passwordResponse `json:"password"` +} + +type scopeResponse struct { + Project nameResponse `json:"project"` +} + +type authResponse struct { + Identity identityResponse `json:"identity"` + Scope scopeResponse `json:"scope"` +} + +type loginResponse struct { + Auth authResponse `json:"auth"` +} + +type endpointResponse struct { + Token struct { + Catalog []struct { + Type string `json:"type"` + Endpoints []struct { + URL string `json:"url"` + } `json:"endpoints"` + } `json:"catalog"` + } `json:"token"` +} + +type zoneItem struct { + ID string `json:"id"` +} + +type zonesResponse struct { + Zones []zoneItem `json:"zones"` +} + +type recordSet struct { + ID string `json:"id"` +} + +type recordSetsResponse struct { + RecordSets []recordSet `json:"recordsets"` +} diff --git a/providers/dns/otc/otc.go b/providers/dns/otc/otc.go index 30535f7e..b26934d1 100644 --- a/providers/dns/otc/otc.go +++ b/providers/dns/otc/otc.go @@ -5,27 +5,70 @@ package otc import ( "bytes" "encoding/json" + "errors" "fmt" "io" "io/ioutil" + "net" "net/http" - "os" "time" "github.com/xenolf/lego/acme" "github.com/xenolf/lego/platform/config/env" ) +const defaultIdentityEndpoint = "https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens" + +// minTTL 300 is otc minimum value for ttl +const minTTL = 300 + +// Config is used to configure the creation of the DNSProvider +type Config struct { + IdentityEndpoint string + DomainName string + ProjectName string + UserName string + Password string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + IdentityEndpoint: env.GetOrDefaultString("OTC_IDENTITY_ENDPOINT", defaultIdentityEndpoint), + PropagationTimeout: env.GetOrDefaultSecond("OTC_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("OTC_POLLING_INTERVAL", acme.DefaultPollingInterval), + TTL: env.GetOrDefaultInt("OTC_TTL", minTTL), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("OTC_HTTP_TIMEOUT", 10*time.Second), + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + + // Workaround for keep alive bug in otc api + DisableKeepAlives: true, + }, + }, + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface that uses // OTC's Managed DNS API to manage TXT records for a domain. type DNSProvider struct { - identityEndpoint string - otcBaseURL string - domainName string - projectName string - userName string - password string - token string + config *Config + baseURL string + token string } // NewDNSProvider returns a DNSProvider instance configured for OTC DNS. @@ -34,41 +77,129 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("OTC_DOMAIN_NAME", "OTC_USER_NAME", "OTC_PASSWORD", "OTC_PROJECT_NAME") if err != nil { - return nil, fmt.Errorf("OTC: %v", err) + return nil, fmt.Errorf("otc: %v", err) } - return NewDNSProviderCredentials( - values["OTC_DOMAIN_NAME"], - values["OTC_USER_NAME"], - values["OTC_PASSWORD"], - values["OTC_PROJECT_NAME"], - os.Getenv("OTC_IDENTITY_ENDPOINT"), - ) + config := NewDefaultConfig() + config.DomainName = values["OTC_DOMAIN_NAME"] + config.UserName = values["OTC_USER_NAME"] + config.Password = values["OTC_PASSWORD"] + config.ProjectName = values["OTC_PROJECT_NAME"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for OTC DNS. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for OTC DNS. +// Deprecated func NewDNSProviderCredentials(domainName, userName, password, projectName, identityEndpoint string) (*DNSProvider, error) { - if domainName == "" || userName == "" || password == "" || projectName == "" { - return nil, fmt.Errorf("OTC credentials missing") - } + config := NewDefaultConfig() + config.IdentityEndpoint = identityEndpoint + config.DomainName = domainName + config.UserName = userName + config.Password = password + config.ProjectName = projectName - if identityEndpoint == "" { - identityEndpoint = "https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens" - } - - return &DNSProvider{ - identityEndpoint: identityEndpoint, - domainName: domainName, - userName: userName, - password: password, - projectName: projectName, - }, nil + return NewDNSProviderConfig(config) } -// SendRequest send request -func (d *DNSProvider) SendRequest(method, resource string, payload interface{}) (io.Reader, error) { - url := fmt.Sprintf("%s/%s", d.otcBaseURL, resource) +// NewDNSProviderConfig return a DNSProvider instance configured for OTC DNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("otc: the configuration of the DNS provider is nil") + } + + if config.DomainName == "" || config.UserName == "" || config.Password == "" || config.ProjectName == "" { + return nil, fmt.Errorf("otc: credentials missing") + } + + if config.IdentityEndpoint == "" { + config.IdentityEndpoint = defaultIdentityEndpoint + } + + return &DNSProvider{config: config}, nil +} + +// Present creates a TXT record using the specified parameters +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + if d.config.TTL < minTTL { + d.config.TTL = minTTL + } + + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return fmt.Errorf("otc: %v", err) + } + + err = d.login() + if err != nil { + return fmt.Errorf("otc: %v", err) + } + + zoneID, err := d.getZoneID(authZone) + if err != nil { + return fmt.Errorf("otc: unable to get zone: %s", err) + } + + resource := fmt.Sprintf("zones/%s/recordsets", zoneID) + + r1 := &recordset{ + Name: fqdn, + Description: "Added TXT record for ACME dns-01 challenge using lego client", + Type: "TXT", + TTL: d.config.TTL, + Records: []string{fmt.Sprintf("\"%s\"", value)}, + } + + _, err = d.sendRequest(http.MethodPost, resource, r1) + if err != nil { + return fmt.Errorf("otc: %v", 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 fmt.Errorf("otc: %v", err) + } + + err = d.login() + if err != nil { + return fmt.Errorf("otc: %v", err) + } + + zoneID, err := d.getZoneID(authZone) + if err != nil { + return fmt.Errorf("otc: %v", err) + } + + recordID, err := d.getRecordSetID(zoneID, fqdn) + if err != nil { + return fmt.Errorf("otc: unable go get record %s for zone %s: %s", fqdn, domain, err) + } + + err = d.deleteRecordSet(zoneID, recordID) + if err != nil { + return fmt.Errorf("otc: %v", err) + } + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// sendRequest send request +func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) (io.Reader, error) { + url := fmt.Sprintf("%s/%s", d.baseURL, resource) body, err := json.Marshal(payload) if err != nil { @@ -84,15 +215,7 @@ func (d *DNSProvider) SendRequest(method, resource string, payload interface{}) req.Header.Set("X-Auth-Token", d.token) } - // Workaround for keep alive bug in otc api - tr := http.DefaultTransport.(*http.Transport) - tr.DisableKeepAlives = true - - client := &http.Client{ - Timeout: 10 * time.Second, - Transport: tr, - } - resp, err := client.Do(req) + resp, err := d.config.HTTPClient.Do(req) if err != nil { return nil, err } @@ -111,42 +234,11 @@ func (d *DNSProvider) SendRequest(method, resource string, payload interface{}) } func (d *DNSProvider) loginRequest() error { - type nameResponse struct { - Name string `json:"name"` - } - - type userResponse struct { - Name string `json:"name"` - Password string `json:"password"` - Domain nameResponse `json:"domain"` - } - - type passwordResponse struct { - User userResponse `json:"user"` - } - type identityResponse struct { - Methods []string `json:"methods"` - Password passwordResponse `json:"password"` - } - - type scopeResponse struct { - Project nameResponse `json:"project"` - } - - type authResponse struct { - Identity identityResponse `json:"identity"` - Scope scopeResponse `json:"scope"` - } - - type loginResponse struct { - Auth authResponse `json:"auth"` - } - userResp := userResponse{ - Name: d.userName, - Password: d.password, + Name: d.config.UserName, + Password: d.config.Password, Domain: nameResponse{ - Name: d.domainName, + Name: d.config.DomainName, }, } @@ -160,7 +252,7 @@ func (d *DNSProvider) loginRequest() error { }, Scope: scopeResponse{ Project: nameResponse{ - Name: d.projectName, + Name: d.config.ProjectName, }, }, }, @@ -170,13 +262,14 @@ func (d *DNSProvider) loginRequest() error { if err != nil { return err } - req, err := http.NewRequest(http.MethodPost, d.identityEndpoint, bytes.NewReader(body)) + + req, err := http.NewRequest(http.MethodPost, d.config.IdentityEndpoint, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: 10 * time.Second} + client := &http.Client{Timeout: d.config.HTTPClient.Timeout} resp, err := client.Do(req) if err != nil { return err @@ -193,16 +286,6 @@ func (d *DNSProvider) loginRequest() error { return fmt.Errorf("unable to get auth token") } - type endpointResponse struct { - Token struct { - Catalog []struct { - Type string `json:"type"` - Endpoints []struct { - URL string `json:"url"` - } `json:"endpoints"` - } `json:"catalog"` - } `json:"token"` - } var endpointResp endpointResponse err = json.NewDecoder(resp.Body).Decode(&endpointResp) @@ -213,13 +296,13 @@ func (d *DNSProvider) loginRequest() error { for _, v := range endpointResp.Token.Catalog { if v.Type == "dns" { for _, endpoint := range v.Endpoints { - d.otcBaseURL = fmt.Sprintf("%s/v2", endpoint.URL) + d.baseURL = fmt.Sprintf("%s/v2", endpoint.URL) continue } } } - if d.otcBaseURL == "" { + if d.baseURL == "" { return fmt.Errorf("unable to get dns endpoint") } @@ -233,16 +316,8 @@ func (d *DNSProvider) login() error { } func (d *DNSProvider) getZoneID(zone string) (string, error) { - type zoneItem struct { - ID string `json:"id"` - } - - type zonesResponse struct { - Zones []zoneItem `json:"zones"` - } - resource := fmt.Sprintf("zones?name=%s", zone) - resp, err := d.SendRequest(http.MethodGet, resource, nil) + resp, err := d.sendRequest(http.MethodGet, resource, nil) if err != nil { return "", err } @@ -269,16 +344,8 @@ func (d *DNSProvider) getZoneID(zone string) (string, error) { } func (d *DNSProvider) getRecordSetID(zoneID string, fqdn string) (string, error) { - type recordSet struct { - ID string `json:"id"` - } - - type recordSetsResponse struct { - RecordSets []recordSet `json:"recordsets"` - } - resource := fmt.Sprintf("zones/%s/recordsets?type=TXT&name=%s", zoneID, fqdn) - resp, err := d.SendRequest(http.MethodGet, resource, nil) + resp, err := d.sendRequest(http.MethodGet, resource, nil) if err != nil { return "", err } @@ -307,77 +374,6 @@ func (d *DNSProvider) getRecordSetID(zoneID string, fqdn string) (string, error) func (d *DNSProvider) deleteRecordSet(zoneID, recordID string) error { resource := fmt.Sprintf("zones/%s/recordsets/%s", zoneID, recordID) - _, err := d.SendRequest(http.MethodDelete, resource, nil) + _, err := d.sendRequest(http.MethodDelete, resource, nil) return err } - -// 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) - - if ttl < 300 { - ttl = 300 // 300 is otc minimum value for ttl - } - - authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) - if err != nil { - return err - } - - err = d.login() - if err != nil { - return err - } - - zoneID, err := d.getZoneID(authZone) - if err != nil { - return fmt.Errorf("unable to get zone: %s", err) - } - - resource := fmt.Sprintf("zones/%s/recordsets", zoneID) - - type recordset struct { - Name string `json:"name"` - Description string `json:"description"` - Type string `json:"type"` - TTL int `json:"ttl"` - Records []string `json:"records"` - } - - r1 := &recordset{ - Name: fqdn, - Description: "Added TXT record for ACME dns-01 challenge using lego client", - Type: "TXT", - TTL: ttl, - Records: []string{fmt.Sprintf("\"%s\"", value)}, - } - _, err = d.SendRequest(http.MethodPost, resource, r1) - return err -} - -// 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 - } - - zoneID, err := d.getZoneID(authZone) - if err != nil { - return err - } - - recordID, err := d.getRecordSetID(zoneID, fqdn) - if err != nil { - return fmt.Errorf("unable go get record %s for zone %s: %s", fqdn, domain, err) - } - - return d.deleteRecordSet(zoneID, recordID) -} diff --git a/providers/dns/otc/otc_test.go b/providers/dns/otc/otc_test.go index dfee06a4..37a2983b 100644 --- a/providers/dns/otc/otc_test.go +++ b/providers/dns/otc/otc_test.go @@ -31,7 +31,15 @@ func TestOTCDNSTestSuite(t *testing.T) { func (s *OTCDNSTestSuite) createDNSProvider() (*DNSProvider, error) { url := fmt.Sprintf("%s/v3/auth/token", s.Mock.Server.URL) - return NewDNSProviderCredentials(fakeOTCUserName, fakeOTCPassword, fakeOTCDomainName, fakeOTCProjectName, url) + + config := NewDefaultConfig() + config.UserName = fakeOTCUserName + config.Password = fakeOTCPassword + config.DomainName = fakeOTCDomainName + config.ProjectName = fakeOTCProjectName + config.IdentityEndpoint = url + + return NewDNSProviderConfig(config) } func (s *OTCDNSTestSuite) TestOTCDNSLoginEnv() { @@ -45,24 +53,24 @@ func (s *OTCDNSTestSuite) TestOTCDNSLoginEnv() { provider, err := NewDNSProvider() assert.Nil(s.T(), err) - assert.Equal(s.T(), provider.domainName, "unittest1") - assert.Equal(s.T(), provider.userName, "unittest2") - assert.Equal(s.T(), provider.password, "unittest3") - assert.Equal(s.T(), provider.projectName, "unittest4") - assert.Equal(s.T(), provider.identityEndpoint, "unittest5") + assert.Equal(s.T(), provider.config.DomainName, "unittest1") + assert.Equal(s.T(), provider.config.UserName, "unittest2") + assert.Equal(s.T(), provider.config.Password, "unittest3") + assert.Equal(s.T(), provider.config.ProjectName, "unittest4") + assert.Equal(s.T(), provider.config.IdentityEndpoint, "unittest5") os.Setenv("OTC_IDENTITY_ENDPOINT", "") provider, err = NewDNSProvider() assert.Nil(s.T(), err) - assert.Equal(s.T(), provider.identityEndpoint, "https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens") + assert.Equal(s.T(), provider.config.IdentityEndpoint, "https://iam.eu-de.otc.t-systems.com:443/v3/auth/tokens") } func (s *OTCDNSTestSuite) TestOTCDNSLoginEnvEmpty() { defer os.Clearenv() _, err := NewDNSProvider() - assert.EqualError(s.T(), err, "OTC: some credentials information are missing: OTC_DOMAIN_NAME,OTC_USER_NAME,OTC_PASSWORD,OTC_PROJECT_NAME") + assert.EqualError(s.T(), err, "otc: some credentials information are missing: OTC_DOMAIN_NAME,OTC_USER_NAME,OTC_PASSWORD,OTC_PROJECT_NAME") } func (s *OTCDNSTestSuite) TestOTCDNSLogin() { @@ -71,7 +79,7 @@ func (s *OTCDNSTestSuite) TestOTCDNSLogin() { assert.Nil(s.T(), err) err = otcProvider.loginRequest() assert.Nil(s.T(), err) - assert.Equal(s.T(), otcProvider.otcBaseURL, fmt.Sprintf("%s/v2", s.Mock.Server.URL)) + assert.Equal(s.T(), otcProvider.baseURL, fmt.Sprintf("%s/v2", s.Mock.Server.URL)) assert.Equal(s.T(), fakeOTCToken, otcProvider.token) } diff --git a/providers/dns/ovh/ovh.go b/providers/dns/ovh/ovh.go index 0a35f896..c633ea7d 100644 --- a/providers/dns/ovh/ovh.go +++ b/providers/dns/ovh/ovh.go @@ -3,9 +3,12 @@ package ovh import ( + "errors" "fmt" + "net/http" "strings" "sync" + "time" "github.com/ovh/go-ovh/ovh" "github.com/xenolf/lego/acme" @@ -15,9 +18,34 @@ import ( // OVH API reference: https://eu.api.ovh.com/ // Create a Token: https://eu.api.ovh.com/createToken/ +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIEndpoint string + ApplicationKey string + ApplicationSecret string + ConsumerKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("OVH_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("OVH_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("OVH_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("OVH_HTTP_TIMEOUT", ovh.DefaultTimeout), + }, + } +} + // 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 { + config *Config client *ovh.Client recordIDs map[string]int recordIDsMu sync.Mutex @@ -32,69 +60,88 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("OVH_ENDPOINT", "OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY") if err != nil { - return nil, fmt.Errorf("OVH: %v", err) + return nil, fmt.Errorf("ovh: %v", err) } - return NewDNSProviderCredentials( - values["OVH_ENDPOINT"], - values["OVH_APPLICATION_KEY"], - values["OVH_APPLICATION_SECRET"], - values["OVH_CONSUMER_KEY"], - ) + config := NewDefaultConfig() + config.APIEndpoint = values["OVH_ENDPOINT"] + config.ApplicationKey = values["OVH_APPLICATION_KEY"] + config.ApplicationSecret = values["OVH_APPLICATION_SECRET"] + config.ConsumerKey = values["OVH_CONSUMER_KEY"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for OVH. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for OVH. +// Deprecated func NewDNSProviderCredentials(apiEndpoint, applicationKey, applicationSecret, consumerKey string) (*DNSProvider, error) { - if apiEndpoint == "" || applicationKey == "" || applicationSecret == "" || consumerKey == "" { - return nil, fmt.Errorf("OVH credentials missing") + config := NewDefaultConfig() + config.APIEndpoint = apiEndpoint + config.ApplicationKey = applicationKey + config.ApplicationSecret = applicationSecret + config.ConsumerKey = consumerKey + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for OVH. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("ovh: the configuration of the DNS provider is nil") } - ovhClient, err := ovh.NewClient( - apiEndpoint, - applicationKey, - applicationSecret, - consumerKey, + if config.APIEndpoint == "" || config.ApplicationKey == "" || config.ApplicationSecret == "" || config.ConsumerKey == "" { + return nil, fmt.Errorf("ovh: credentials missing") + } + + client, err := ovh.NewClient( + config.APIEndpoint, + config.ApplicationKey, + config.ApplicationSecret, + config.ConsumerKey, ) - if err != nil { - return nil, err + return nil, fmt.Errorf("ovh: %v", err) } + client.Client = config.HTTPClient + return &DNSProvider{ - client: ovhClient, + config: config, + client: client, 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 { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + fqdn, value, _ := 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) + return fmt.Errorf("ovh: 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} + reqData := txtRecordRequest{FieldType: "TXT", SubDomain: subDomain, Target: value, TTL: d.config.TTL} var respData txtRecordResponse // Create TXT record err = d.client.Post(reqURL, reqData, &respData) if err != nil { - return fmt.Errorf("error when call OVH api to add record: %v", err) + return fmt.Errorf("ovh: error when call api to add record: %v", err) } // Apply the change reqURL = fmt.Sprintf("/domain/zone/%s/refresh", authZone) err = d.client.Post(reqURL, nil, nil) if err != nil { - return fmt.Errorf("error when call OVH api to refresh zone: %v", err) + return fmt.Errorf("ovh: error when call api to refresh zone: %v", err) } d.recordIDsMu.Lock() @@ -113,12 +160,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { recordID, ok := d.recordIDs[fqdn] d.recordIDsMu.Unlock() if !ok { - return fmt.Errorf("unknown record ID for '%s'", fqdn) + return fmt.Errorf("ovh: 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) + return fmt.Errorf("ovh: could not determine zone for domain: '%s'. %s", domain, err) } authZone = acme.UnFqdn(authZone) @@ -127,7 +174,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { err = d.client.Delete(reqURL, nil) if err != nil { - return fmt.Errorf("error when call OVH api to delete challenge record: %v", err) + return fmt.Errorf("ovh: error when call OVH api to delete challenge record: %v", err) } // Delete record ID from map @@ -138,6 +185,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return nil } +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + func (d *DNSProvider) extractRecordName(fqdn, domain string) string { name := acme.UnFqdn(fqdn) if idx := strings.Index(name, "."+domain); idx != -1 { diff --git a/providers/dns/ovh/ovh_test.go b/providers/dns/ovh/ovh_test.go index 04d34a0f..8c851f82 100644 --- a/providers/dns/ovh/ovh_test.go +++ b/providers/dns/ovh/ovh_test.go @@ -59,7 +59,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { "OVH_APPLICATION_SECRET": "5678", "OVH_CONSUMER_KEY": "abcde", }, - expected: "OVH: some credentials information are missing: OVH_ENDPOINT", + expected: "ovh: some credentials information are missing: OVH_ENDPOINT", }, { desc: "missing OVH_APPLICATION_KEY", @@ -69,7 +69,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { "OVH_APPLICATION_SECRET": "5678", "OVH_CONSUMER_KEY": "abcde", }, - expected: "OVH: some credentials information are missing: OVH_APPLICATION_KEY", + expected: "ovh: some credentials information are missing: OVH_APPLICATION_KEY", }, { desc: "missing OVH_APPLICATION_SECRET", @@ -79,7 +79,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { "OVH_APPLICATION_SECRET": "", "OVH_CONSUMER_KEY": "abcde", }, - expected: "OVH: some credentials information are missing: OVH_APPLICATION_SECRET", + expected: "ovh: some credentials information are missing: OVH_APPLICATION_SECRET", }, { desc: "missing OVH_CONSUMER_KEY", @@ -89,7 +89,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { "OVH_APPLICATION_SECRET": "5678", "OVH_CONSUMER_KEY": "", }, - expected: "OVH: some credentials information are missing: OVH_CONSUMER_KEY", + expected: "ovh: some credentials information are missing: OVH_CONSUMER_KEY", }, { desc: "all missing", @@ -99,7 +99,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { "OVH_APPLICATION_SECRET": "", "OVH_CONSUMER_KEY": "", }, - expected: "OVH: some credentials information are missing: OVH_ENDPOINT,OVH_APPLICATION_KEY,OVH_APPLICATION_SECRET,OVH_CONSUMER_KEY", + expected: "ovh: some credentials information are missing: OVH_ENDPOINT,OVH_APPLICATION_KEY,OVH_APPLICATION_SECRET,OVH_CONSUMER_KEY", }, } diff --git a/providers/dns/pdns/pdns.go b/providers/dns/pdns/pdns.go index 3b9915ac..44c4e433 100644 --- a/providers/dns/pdns/pdns.go +++ b/providers/dns/pdns/pdns.go @@ -5,6 +5,7 @@ package pdns import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -18,12 +19,32 @@ import ( "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIKey string + Host *url.URL + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("PDNS_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("PDNS_PROPAGATION_TIMEOUT", 120*time.Second), + PollingInterval: env.GetOrDefaultSecond("PDNS_POLLING_INTERVAL", 2*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("PDNS_HTTP_TIMEOUT", 30*time.Second), + }, + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface type DNSProvider struct { - apiKey string - host *url.URL apiVersion int - client *http.Client + config *Config } // NewDNSProvider returns a DNSProvider instance configured for pdns. @@ -32,37 +53,51 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("PDNS_API_KEY", "PDNS_API_URL") if err != nil { - return nil, fmt.Errorf("PDNS: %v", err) + return nil, fmt.Errorf("pdns: %v", err) } hostURL, err := url.Parse(values["PDNS_API_URL"]) if err != nil { - return nil, fmt.Errorf("PDNS: %v", err) + return nil, fmt.Errorf("pdns: %v", err) } - return NewDNSProviderCredentials(hostURL, values["PDNS_API_KEY"]) + config := NewDefaultConfig() + config.Host = hostURL + config.APIKey = values["PDNS_API_KEY"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for pdns. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for pdns. +// Deprecated func NewDNSProviderCredentials(host *url.URL, key string) (*DNSProvider, error) { - if key == "" { - return nil, fmt.Errorf("PDNS API key missing") + config := NewDefaultConfig() + config.Host = host + config.APIKey = key + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for pdns. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("pdns: the configuration of the DNS provider is nil") } - if host == nil || host.Host == "" { - return nil, fmt.Errorf("PDNS API URL missing") + if config.APIKey == "" { + return nil, fmt.Errorf("pdns: API key missing") } - d := &DNSProvider{ - host: host, - apiKey: key, - client: &http.Client{Timeout: 30 * time.Second}, + if config.Host == nil || config.Host.Host == "" { + return nil, fmt.Errorf("pdns: API URL missing") } + d := &DNSProvider{config: config} + apiVersion, err := d.getAPIVersion() if err != nil { - log.Warnf("PDNS: failed to get API version %v", err) + log.Warnf("pdns: failed to get API version %v", err) } d.apiVersion = apiVersion @@ -72,7 +107,7 @@ func NewDNSProviderCredentials(host *url.URL, key string) (*DNSProvider, error) // Timeout returns the timeout and interval to use when checking for DNS // propagation. Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return 120 * time.Second, 2 * time.Second + return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfil the dns-01 challenge @@ -80,7 +115,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { fqdn, value, _ := acme.DNS01Record(domain, keyAuth) zone, err := d.getHostedZone(fqdn) if err != nil { - return err + return fmt.Errorf("pdns: %v", err) } name := fqdn @@ -97,7 +132,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // pre-v1 API Type: "TXT", Name: name, - TTL: 120, + TTL: d.config.TTL, } rrsets := rrSets{ @@ -107,7 +142,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { ChangeType: "REPLACE", Type: "TXT", Kind: "Master", - TTL: 120, + TTL: d.config.TTL, Records: []pdnsRecord{rec}, }, }, @@ -115,11 +150,14 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { body, err := json.Marshal(rrsets) if err != nil { - return err + return fmt.Errorf("pdns: %v", err) } _, err = d.makeRequest(http.MethodPatch, zone.URL, bytes.NewReader(body)) - return err + if err != nil { + return fmt.Errorf("pdns: %v", err) + } + return nil } // CleanUp removes the TXT record matching the specified parameters @@ -128,12 +166,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { zone, err := d.getHostedZone(fqdn) if err != nil { - return err + return fmt.Errorf("pdns: %v", err) } set, err := d.findTxtRecord(fqdn) if err != nil { - return err + return fmt.Errorf("pdns: %v", err) } rrsets := rrSets{ @@ -147,11 +185,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { } body, err := json.Marshal(rrsets) if err != nil { - return err + return fmt.Errorf("pdns: %v", err) } _, err = d.makeRequest(http.MethodPatch, zone.URL, bytes.NewReader(body)) - return err + if err != nil { + return fmt.Errorf("pdns: %v", err) + } + return nil } func (d *DNSProvider) getHostedZone(fqdn string) (*hostedZone, error) { @@ -161,8 +202,8 @@ func (d *DNSProvider) getHostedZone(fqdn string) (*hostedZone, error) { return nil, err } - url := "/servers/localhost/zones" - result, err := d.makeRequest(http.MethodGet, url, nil) + u := "/servers/localhost/zones" + result, err := d.makeRequest(http.MethodGet, u, nil) if err != nil { return nil, err } @@ -173,14 +214,14 @@ func (d *DNSProvider) getHostedZone(fqdn string) (*hostedZone, error) { return nil, err } - url = "" + u = "" for _, zone := range zones { if acme.UnFqdn(zone.Name) == acme.UnFqdn(authZone) { - url = zone.URL + u = zone.URL } } - result, err = d.makeRequest(http.MethodGet, url, nil) + result, err = d.makeRequest(http.MethodGet, u, nil) if err != nil { return nil, err } @@ -259,8 +300,8 @@ func (d *DNSProvider) makeRequest(method, uri string, body io.Reader) (json.RawM } var path = "" - if d.host.Path != "/" { - path = d.host.Path + if d.config.Host.Path != "/" { + path = d.config.Host.Path } if !strings.HasPrefix(uri, "/") { @@ -271,15 +312,15 @@ func (d *DNSProvider) makeRequest(method, uri string, body io.Reader) (json.RawM uri = "/api/v" + strconv.Itoa(d.apiVersion) + uri } - url := d.host.Scheme + "://" + d.host.Host + path + uri - req, err := http.NewRequest(method, url, body) + u := d.config.Host.Scheme + "://" + d.config.Host.Host + path + uri + req, err := http.NewRequest(method, u, body) if err != nil { return nil, err } - req.Header.Set("X-API-Key", d.apiKey) + req.Header.Set("X-API-Key", d.config.APIKey) - resp, err := d.client.Do(req) + resp, err := d.config.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("error talking to PDNS API -> %v", err) } @@ -287,7 +328,7 @@ func (d *DNSProvider) makeRequest(method, uri string, body io.Reader) (json.RawM defer resp.Body.Close() if resp.StatusCode != http.StatusUnprocessableEntity && (resp.StatusCode < 200 || resp.StatusCode >= 300) { - return nil, fmt.Errorf("unexpected HTTP status code %d when fetching '%s'", resp.StatusCode, url) + return nil, fmt.Errorf("unexpected HTTP status code %d when fetching '%s'", resp.StatusCode, u) } var msg json.RawMessage diff --git a/providers/dns/pdns/pdns_test.go b/providers/dns/pdns/pdns_test.go index c436d4fb..a13392d3 100644 --- a/providers/dns/pdns/pdns_test.go +++ b/providers/dns/pdns/pdns_test.go @@ -37,7 +37,12 @@ func TestNewDNSProviderValid(t *testing.T) { os.Setenv("PDNS_API_KEY", "") tmpURL, _ := url.Parse("http://localhost:8081") - _, err := NewDNSProviderCredentials(tmpURL, "123") + + config := NewDefaultConfig() + config.Host = tmpURL + config.APIKey = "123" + + _, err := NewDNSProviderConfig(config) assert.NoError(t, err) } @@ -56,7 +61,7 @@ func TestNewDNSProviderMissingHostErr(t *testing.T) { os.Setenv("PDNS_API_KEY", "123") _, err := NewDNSProvider() - assert.EqualError(t, err, "PDNS: some credentials information are missing: PDNS_API_URL") + assert.EqualError(t, err, "pdns: some credentials information are missing: PDNS_API_URL") } func TestNewDNSProviderMissingKeyErr(t *testing.T) { @@ -65,7 +70,7 @@ func TestNewDNSProviderMissingKeyErr(t *testing.T) { os.Setenv("PDNS_API_KEY", "") _, err := NewDNSProvider() - assert.EqualError(t, err, "PDNS: some credentials information are missing: PDNS_API_KEY,PDNS_API_URL") + assert.EqualError(t, err, "pdns: some credentials information are missing: PDNS_API_KEY,PDNS_API_URL") } func TestPdnsPresentAndCleanup(t *testing.T) { @@ -73,7 +78,11 @@ func TestPdnsPresentAndCleanup(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderCredentials(pdnsURL, pdnsAPIKey) + config := NewDefaultConfig() + config.Host = pdnsURL + config.APIKey = pdnsAPIKey + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.Present(pdnsDomain, "", "123d==") diff --git a/providers/dns/rackspace/client.go b/providers/dns/rackspace/client.go new file mode 100644 index 00000000..ce203276 --- /dev/null +++ b/providers/dns/rackspace/client.go @@ -0,0 +1,47 @@ +package rackspace + +// APIKeyCredentials API credential +type APIKeyCredentials struct { + Username string `json:"username"` + APIKey string `json:"apiKey"` +} + +// Auth auth credentials +type Auth struct { + APIKeyCredentials `json:"RAX-KSKEY:apiKeyCredentials"` +} + +// AuthData Auth data +type AuthData struct { + Auth `json:"auth"` +} + +// Identity Identity +type Identity struct { + Access struct { + ServiceCatalog []struct { + Endpoints []struct { + PublicURL string `json:"publicURL"` + TenantID string `json:"tenantId"` + } `json:"endpoints"` + Name string `json:"name"` + } `json:"serviceCatalog"` + Token struct { + ID string `json:"id"` + } `json:"token"` + } `json:"access"` +} + +// Records is the list of records sent/received from the DNS API +type Records struct { + Record []Record `json:"records"` +} + +// Record represents a Rackspace DNS record +type Record struct { + Name string `json:"name"` + Type string `json:"type"` + Data string `json:"data"` + TTL int `json:"ttl,omitempty"` + ID string `json:"id,omitempty"` +} diff --git a/providers/dns/rackspace/rackspace.go b/providers/dns/rackspace/rackspace.go index 358af69d..8e56f20a 100644 --- a/providers/dns/rackspace/rackspace.go +++ b/providers/dns/rackspace/rackspace.go @@ -5,6 +5,7 @@ package rackspace import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -14,42 +15,85 @@ import ( "github.com/xenolf/lego/platform/config/env" ) -// rackspaceAPIURL represents the Identity API endpoint to call -var rackspaceAPIURL = "https://identity.api.rackspacecloud.com/v2.0/tokens" +// defaultBaseURL represents the Identity API endpoint to call +const defaultBaseURL = "https://identity.api.rackspacecloud.com/v2.0/tokens" + +// Config is used to configure the creation of the DNSProvider +type Config struct { + BaseURL string + APIUser string + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + BaseURL: defaultBaseURL, + TTL: env.GetOrDefaultInt("RACKSPACE_TTL", 300), + PropagationTimeout: env.GetOrDefaultSecond("RACKSPACE_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("RACKSPACE_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("RACKSPACE_HTTP_TIMEOUT", 30*time.Second), + }, + } +} // DNSProvider is an implementation of the acme.ChallengeProvider interface // used to store the reusable token and DNS API endpoint type DNSProvider struct { + config *Config token string cloudDNSEndpoint string - client *http.Client } // NewDNSProvider returns a DNSProvider instance configured for Rackspace. -// Credentials must be passed in the environment variables: RACKSPACE_USER -// and RACKSPACE_API_KEY. +// Credentials must be passed in the environment variables: +// RACKSPACE_USER and RACKSPACE_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("RACKSPACE_USER", "RACKSPACE_API_KEY") if err != nil { - return nil, fmt.Errorf("Rackspace: %v", err) + return nil, fmt.Errorf("rackspace: %v", err) } - return NewDNSProviderCredentials(values["RACKSPACE_USER"], values["RACKSPACE_API_KEY"]) + config := NewDefaultConfig() + config.APIUser = values["RACKSPACE_USER"] + config.APIKey = values["RACKSPACE_API_KEY"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for Rackspace. It authenticates against -// the API, also grabbing the DNS Endpoint. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for Rackspace. +// It authenticates against the API, also grabbing the DNS Endpoint. +// Deprecated func NewDNSProviderCredentials(user, key string) (*DNSProvider, error) { - if user == "" || key == "" { - return nil, fmt.Errorf("Rackspace credentials missing") + config := NewDefaultConfig() + config.APIUser = user + config.APIKey = key + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Rackspace. +// It authenticates against the API, also grabbing the DNS Endpoint. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("rackspace: the configuration of the DNS provider is nil") + } + + if config.APIUser == "" || config.APIKey == "" { + return nil, fmt.Errorf("rackspace: credentials missing") } authData := AuthData{ Auth: Auth{ APIKeyCredentials: APIKeyCredentials{ - Username: user, - APIKey: key, + Username: config.APIUser, + APIKey: config.APIKey, }, }, } @@ -59,46 +103,47 @@ func NewDNSProviderCredentials(user, key string) (*DNSProvider, error) { return nil, err } - req, err := http.NewRequest(http.MethodPost, rackspaceAPIURL, bytes.NewReader(body)) + req, err := http.NewRequest(http.MethodPost, config.BaseURL, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + // client := &http.Client{Timeout: 30 * time.Second} + resp, err := config.HTTPClient.Do(req) if err != nil { - return nil, fmt.Errorf("error querying Rackspace Identity API: %v", err) + return nil, fmt.Errorf("rackspace: error querying Identity API: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("Rackspace Authentication failed. Response code: %d", resp.StatusCode) + return nil, fmt.Errorf("rackspace: authentication failed: response code: %d", resp.StatusCode) } - var rackspaceIdentity Identity - err = json.NewDecoder(resp.Body).Decode(&rackspaceIdentity) + var identity Identity + err = json.NewDecoder(resp.Body).Decode(&identity) if err != nil { - return nil, err + return nil, fmt.Errorf("rackspace: %v", err) } // Iterate through the Service Catalog to get the DNS Endpoint var dnsEndpoint string - for _, service := range rackspaceIdentity.Access.ServiceCatalog { + for _, service := range identity.Access.ServiceCatalog { if service.Name == "cloudDNS" { dnsEndpoint = service.Endpoints[0].PublicURL break } } if dnsEndpoint == "" { - return nil, fmt.Errorf("failed to populate DNS endpoint, check Rackspace API for changes") + return nil, fmt.Errorf("rackspace: failed to populate DNS endpoint, check Rackspace API for changes") } return &DNSProvider{ - token: rackspaceIdentity.Access.Token.ID, + config: config, + token: identity.Access.Token.ID, cloudDNSEndpoint: dnsEndpoint, - client: client, }, nil + } // Present creates a TXT record to fulfil the dns-01 challenge @@ -106,7 +151,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { fqdn, value, _ := acme.DNS01Record(domain, keyAuth) zoneID, err := d.getHostedZoneID(fqdn) if err != nil { - return err + return fmt.Errorf("rackspace: %v", err) } rec := Records{ @@ -114,17 +159,20 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { Name: acme.UnFqdn(fqdn), Type: "TXT", Data: value, - TTL: 300, + TTL: d.config.TTL, }}, } body, err := json.Marshal(rec) if err != nil { - return err + return fmt.Errorf("rackspace: %v", err) } _, err = d.makeRequest(http.MethodPost, fmt.Sprintf("/domains/%d/records", zoneID), bytes.NewReader(body)) - return err + if err != nil { + return fmt.Errorf("rackspace: %v", err) + } + return nil } // CleanUp removes the TXT record matching the specified parameters @@ -132,16 +180,25 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { fqdn, _, _ := acme.DNS01Record(domain, keyAuth) zoneID, err := d.getHostedZoneID(fqdn) if err != nil { - return err + return fmt.Errorf("rackspace: %v", err) } record, err := d.findTxtRecord(fqdn, zoneID) if err != nil { - return err + return fmt.Errorf("rackspace: %v", err) } _, err = d.makeRequest(http.MethodDelete, fmt.Sprintf("/domains/%d/records?id=%s", zoneID, record.ID), nil) - return err + if err != nil { + return fmt.Errorf("rackspace: %v", err) + } + return nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } // getHostedZoneID performs a lookup to get the DNS zone which needs @@ -216,8 +273,7 @@ func (d *DNSProvider) makeRequest(method, uri string, body io.Reader) (json.RawM req.Header.Set("X-Auth-Token", d.token) req.Header.Set("Content-Type", "application/json") - client := http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + resp, err := d.config.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("error querying DNS API: %v", err) } @@ -236,49 +292,3 @@ func (d *DNSProvider) makeRequest(method, uri string, body io.Reader) (json.RawM return r, nil } - -// APIKeyCredentials API credential -type APIKeyCredentials struct { - Username string `json:"username"` - APIKey string `json:"apiKey"` -} - -// Auth auth credentials -type Auth struct { - APIKeyCredentials `json:"RAX-KSKEY:apiKeyCredentials"` -} - -// AuthData Auth data -type AuthData struct { - Auth `json:"auth"` -} - -// Identity Identity -type Identity struct { - Access struct { - ServiceCatalog []struct { - Endpoints []struct { - PublicURL string `json:"publicURL"` - TenantID string `json:"tenantId"` - } `json:"endpoints"` - Name string `json:"name"` - } `json:"serviceCatalog"` - Token struct { - ID string `json:"id"` - } `json:"token"` - } `json:"access"` -} - -// Records is the list of records sent/received from the DNS API -type Records struct { - Record []Record `json:"records"` -} - -// Record represents a Rackspace DNS record -type Record struct { - Name string `json:"name"` - Type string `json:"type"` - Data string `json:"data"` - TTL int `json:"ttl,omitempty"` - ID string `json:"id,omitempty"` -} diff --git a/providers/dns/rackspace/rackspace_test.go b/providers/dns/rackspace/rackspace_test.go index 0f8c7832..666c4770 100644 --- a/providers/dns/rackspace/rackspace_test.go +++ b/providers/dns/rackspace/rackspace_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var ( @@ -31,13 +32,11 @@ func init() { } func testRackspaceEnv() { - rackspaceAPIURL = testAPIURL os.Setenv("RACKSPACE_USER", "testUser") os.Setenv("RACKSPACE_API_KEY", "testKey") } func liveRackspaceEnv() { - rackspaceAPIURL = "https://identity.api.rackspacecloud.com/v2.0/tokens" os.Setenv("RACKSPACE_USER", rackspaceUser) os.Setenv("RACKSPACE_API_KEY", rackspaceAPIKey) } @@ -134,31 +133,50 @@ func dnsMux() *http.ServeMux { func TestNewDNSProviderMissingCredErr(t *testing.T) { testRackspaceEnv() - _, err := NewDNSProviderCredentials("", "") - assert.EqualError(t, err, "Rackspace credentials missing") + + _, err := NewDNSProviderConfig(&Config{}) + assert.EqualError(t, err, "rackspace: credentials missing") } func TestOfflineRackspaceValid(t *testing.T) { testRackspaceEnv() - provider, err := NewDNSProviderCredentials(os.Getenv("RACKSPACE_USER"), os.Getenv("RACKSPACE_API_KEY")) - assert.NoError(t, err) + config := NewDefaultConfig() + config.BaseURL = testAPIURL + config.APIKey = os.Getenv("RACKSPACE_API_KEY") + config.APIUser = os.Getenv("RACKSPACE_USER") + + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) + assert.Equal(t, provider.token, "testToken", "The token should match") } func TestOfflineRackspacePresent(t *testing.T) { testRackspaceEnv() - provider, err := NewDNSProvider() + + config := NewDefaultConfig() + config.APIUser = os.Getenv("RACKSPACE_USER") + config.APIKey = os.Getenv("RACKSPACE_API_KEY") + config.BaseURL = testAPIURL + + provider, err := NewDNSProviderConfig(config) if assert.NoError(t, err) { err = provider.Present("example.com", "token", "keyAuth") - assert.NoError(t, err) + require.NoError(t, err) } } func TestOfflineRackspaceCleanUp(t *testing.T) { testRackspaceEnv() - provider, err := NewDNSProvider() + + config := NewDefaultConfig() + config.APIUser = os.Getenv("RACKSPACE_USER") + config.APIKey = os.Getenv("RACKSPACE_API_KEY") + config.BaseURL = testAPIURL + + provider, err := NewDNSProviderConfig(config) if assert.NoError(t, err) { err = provider.CleanUp("example.com", "token", "keyAuth") diff --git a/providers/dns/rfc2136/rfc2136.go b/providers/dns/rfc2136/rfc2136.go index ee0b7c14..792ee8b0 100644 --- a/providers/dns/rfc2136/rfc2136.go +++ b/providers/dns/rfc2136/rfc2136.go @@ -3,6 +3,7 @@ package rfc2136 import ( + "errors" "fmt" "net" "os" @@ -11,16 +12,37 @@ import ( "github.com/miekg/dns" "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/platform/config/env" ) +const defaultTimeout = 60 * time.Second + +// Config is used to configure the creation of the DNSProvider +type Config struct { + Nameserver string + TSIGAlgorithm string + TSIGKey string + TSIGSecret string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TSIGAlgorithm: env.GetOrDefaultString("RFC2136_TSIG_ALGORITHM", dns.HmacMD5), + TTL: env.GetOrDefaultInt("RFC2136_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("RFC2136_PROPAGATION_TIMEOUT", + env.GetOrDefaultSecond("RFC2136_TIMEOUT", 60*time.Second)), + PollingInterval: env.GetOrDefaultSecond("RFC2136_POLLING_INTERVAL", 2*time.Second), + } +} + // 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 - timeout time.Duration + config *Config } // NewDNSProvider returns a DNSProvider instance configured for rfc2136 @@ -33,81 +55,110 @@ type DNSProvider struct { // RFC2136_TIMEOUT: DNS propagation timeout in time.ParseDuration format. (60s) // To disable TSIG authentication, leave the RFC2136_TSIG* variables unset. 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") - timeout := os.Getenv("RFC2136_TIMEOUT") + values, err := env.Get("RFC2136_NAMESERVER") + if err != nil { + return nil, fmt.Errorf("rfc2136: %v", err) + } - return NewDNSProviderCredentials(nameserver, tsigAlgorithm, tsigKey, tsigSecret, timeout) + config := NewDefaultConfig() + config.Nameserver = values["RFC2136_NAMESERVER"] + config.TSIGKey = os.Getenv("RFC2136_TSIG_KEY") + config.TSIGSecret = os.Getenv("RFC2136_TSIG_SECRET") + + return NewDNSProviderConfig(config) } -// 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. +// 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, timeout string) (*DNSProvider, error) { - if nameserver == "" { - return nil, fmt.Errorf("RFC2136 nameserver missing") - } +// Deprecated +func NewDNSProviderCredentials(nameserver, tsigAlgorithm, tsigKey, tsigSecret, rawTimeout string) (*DNSProvider, error) { + config := NewDefaultConfig() + config.Nameserver = nameserver + config.TSIGAlgorithm = tsigAlgorithm + config.TSIGKey = tsigKey + config.TSIGSecret = tsigSecret - // 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 - } - - if timeout == "" { - d.timeout = 60 * time.Second - } else { - t, err := time.ParseDuration(timeout) + timeout := defaultTimeout + if rawTimeout != "" { + t, err := time.ParseDuration(rawTimeout) if err != nil { return nil, err } else if t < 0 { - return nil, fmt.Errorf("invalid/negative RFC2136_TIMEOUT: %v", timeout) + return nil, fmt.Errorf("rfc2136: invalid/negative RFC2136_TIMEOUT: %v", rawTimeout) } else { - d.timeout = t + timeout = t + } + } + config.PropagationTimeout = timeout + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for rfc2136. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("rfc2136: the configuration of the DNS provider is nil") + } + + if config.Nameserver == "" { + return nil, fmt.Errorf("rfc2136: nameserver missing") + } + + if config.TSIGAlgorithm == "" { + config.TSIGAlgorithm = dns.HmacMD5 + } + + // Append the default DNS port if none is specified. + if _, _, err := net.SplitHostPort(config.Nameserver); err != nil { + if strings.Contains(err.Error(), "missing port") { + config.Nameserver = net.JoinHostPort(config.Nameserver, "53") + } else { + return nil, fmt.Errorf("rfc2136: %v", err) } } - return d, nil + if len(config.TSIGKey) == 0 && len(config.TSIGSecret) > 0 || + len(config.TSIGKey) > 0 && len(config.TSIGSecret) == 0 { + config.TSIGKey = "" + config.TSIGSecret = "" + } + + return &DNSProvider{config: config}, nil } -// Timeout Returns the timeout configured with RFC2136_TIMEOUT, or 60s. +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.timeout, 2 * time.Second + return d.config.PropagationTimeout, d.config.PollingInterval } // 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) - return d.changeRecord("INSERT", fqdn, value, ttl) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + err := d.changeRecord("INSERT", fqdn, value, d.config.TTL) + if err != nil { + return fmt.Errorf("rfc2136: %v", err) + } + return nil } // CleanUp removes the TXT record matching the specified parameters func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) - return d.changeRecord("REMOVE", fqdn, value, ttl) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + err := d.changeRecord("REMOVE", fqdn, value, d.config.TTL) + if err != nil { + return fmt.Errorf("rfc2136: %v", err) + } + return nil } func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { // Find the zone for the given fqdn - zone, err := acme.FindZoneByFqdn(fqdn, []string{d.nameserver}) + zone, err := acme.FindZoneByFqdn(fqdn, []string{d.config.Nameserver}) if err != nil { return err } @@ -135,14 +186,15 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { // Setup client c := new(dns.Client) c.SingleInflight = true + // TSIG authentication / msg signing - if len(d.tsigKey) > 0 && len(d.tsigSecret) > 0 { - m.SetTsig(dns.Fqdn(d.tsigKey), d.tsigAlgorithm, 300, time.Now().Unix()) - c.TsigSecret = map[string]string{dns.Fqdn(d.tsigKey): d.tsigSecret} + if len(d.config.TSIGKey) > 0 && len(d.config.TSIGSecret) > 0 { + m.SetTsig(dns.Fqdn(d.config.TSIGKey), d.config.TSIGAlgorithm, 300, time.Now().Unix()) + c.TsigSecret = map[string]string{dns.Fqdn(d.config.TSIGKey): d.config.TSIGSecret} } // Send the query - reply, _, err := c.Exchange(m, d.nameserver) + reply, _, err := c.Exchange(m, d.config.Nameserver) if err != nil { return fmt.Errorf("DNS update failed: %v", err) } diff --git a/providers/dns/rfc2136/rfc2136_test.go b/providers/dns/rfc2136/rfc2136_test.go index a92589f0..4c04ba50 100644 --- a/providers/dns/rfc2136/rfc2136_test.go +++ b/providers/dns/rfc2136/rfc2136_test.go @@ -59,7 +59,10 @@ func TestRFC2136ServerSuccess(t *testing.T) { require.NoError(t, err, "Failed to start test server") defer server.Shutdown() - provider, err := NewDNSProviderCredentials(addrstr, "", "", "", "") + config := NewDefaultConfig() + config.Nameserver = addrstr + + provider, err := NewDNSProviderConfig(config) require.NoError(t, err) err = provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth) @@ -75,7 +78,10 @@ func TestRFC2136ServerError(t *testing.T) { require.NoError(t, err, "Failed to start test server") defer server.Shutdown() - provider, err := NewDNSProviderCredentials(addrstr, "", "", "", "") + config := NewDefaultConfig() + config.Nameserver = addrstr + + provider, err := NewDNSProviderConfig(config) require.NoError(t, err) err = provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth) @@ -94,7 +100,12 @@ func TestRFC2136TsigClient(t *testing.T) { require.NoError(t, err, "Failed to start test server") defer server.Shutdown() - provider, err := NewDNSProviderCredentials(addrstr, "", rfc2136TestTsigKey, rfc2136TestTsigSecret, "") + config := NewDefaultConfig() + config.Nameserver = addrstr + config.TSIGKey = rfc2136TestTsigKey + config.TSIGSecret = rfc2136TestTsigSecret + + provider, err := NewDNSProviderConfig(config) require.NoError(t, err) err = provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth) @@ -121,7 +132,10 @@ func TestRFC2136ValidUpdatePacket(t *testing.T) { expect, err := m.Pack() require.NoError(t, err, "error packing") - provider, err := NewDNSProviderCredentials(addrstr, "", "", "", "") + config := NewDefaultConfig() + config.Nameserver = addrstr + + provider, err := NewDNSProviderConfig(config) require.NoError(t, err) err = provider.Present(rfc2136TestDomain, "", "1234d==") diff --git a/providers/dns/route53/route53.go b/providers/dns/route53/route53.go index 71fd4e85..a1d4a8a3 100644 --- a/providers/dns/route53/route53.go +++ b/providers/dns/route53/route53.go @@ -30,13 +30,11 @@ type Config struct { // NewDefaultConfig returns a default configuration for the DNSProvider func NewDefaultConfig() *Config { - propagationMins := env.GetOrDefaultInt("AWS_PROPAGATION_TIMEOUT", 2) - intervalSecs := env.GetOrDefaultInt("AWS_POLLING_INTERVAL", 4) return &Config{ MaxRetries: env.GetOrDefaultInt("AWS_MAX_RETRIES", 5), TTL: env.GetOrDefaultInt("AWS_TTL", 10), - PropagationTimeout: time.Second * time.Duration(propagationMins), - PollingInterval: time.Second * time.Duration(intervalSecs), + PropagationTimeout: env.GetOrDefaultSecond("AWS_PROPAGATION_TIMEOUT", 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond("AWS_POLLING_INTERVAL", 4*time.Second), HostedZoneID: os.Getenv("AWS_HOSTED_ZONE_ID"), } } @@ -91,20 +89,20 @@ func NewDNSProvider() (*DNSProvider, error) { // DNSProvider instance func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { - return nil, errors.New("the configuration of the Route53 DNS provider is nil") + return nil, errors.New("route53: the configuration of the Route53 DNS provider is nil") } r := customRetryer{} r.NumMaxRetries = config.MaxRetries sessionCfg := request.WithRetryer(aws.NewConfig(), r) - session, err := session.NewSessionWithOptions(session.Options{Config: *sessionCfg}) + sess, err := session.NewSessionWithOptions(session.Options{Config: *sessionCfg}) if err != nil { return nil, err } - client := route53.New(session) + cl := route53.New(sess) return &DNSProvider{ - client: client, + client: cl, config: config, }, nil } @@ -118,15 +116,23 @@ func (r *DNSProvider) Timeout() (timeout, interval time.Duration) { // 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, r.config.TTL) + + err := r.changeRecord("UPSERT", fqdn, `"`+value+`"`, r.config.TTL) + if err != nil { + return fmt.Errorf("route53: %v", err) + } + return nil } // 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, r.config.TTL) + + err := r.changeRecord("DELETE", fqdn, `"`+value+`"`, r.config.TTL) + if err != nil { + return fmt.Errorf("route53: %v", err) + } + return nil } func (r *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { @@ -151,7 +157,7 @@ func (r *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { resp, err := r.client.ChangeResourceRecordSets(reqParams) if err != nil { - return fmt.Errorf("failed to change Route 53 record set: %v", err) + return fmt.Errorf("failed to change record set: %v", err) } statusID := resp.ChangeInfo.Id @@ -162,7 +168,7 @@ func (r *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error { } resp, err := r.client.GetChange(reqParams) if err != nil { - return false, fmt.Errorf("failed to query Route 53 change status: %v", err) + return false, fmt.Errorf("failed to query change status: %v", err) } if aws.StringValue(resp.ChangeInfo.Status) == route53.ChangeStatusInsync { return true, nil @@ -200,7 +206,7 @@ func (r *DNSProvider) getHostedZoneID(fqdn string) (string, error) { } if len(hostedZoneID) == 0 { - return "", fmt.Errorf("zone %s not found in Route 53 for domain %s", authZone, fqdn) + return "", fmt.Errorf("zone %s not found for domain %s", authZone, fqdn) } if strings.HasPrefix(hostedZoneID, "/hostedzone/") { diff --git a/providers/dns/sakuracloud/sakuracloud.go b/providers/dns/sakuracloud/sakuracloud.go index 17ca694a..b746fd66 100644 --- a/providers/dns/sakuracloud/sakuracloud.go +++ b/providers/dns/sakuracloud/sakuracloud.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/sacloud/libsacloud/api" "github.com/sacloud/libsacloud/sacloud" @@ -14,8 +15,27 @@ import ( "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + Token string + Secret string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("SAKURACLOUD_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("SAKURACLOUD_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("SAKURACLOUD_POLLING_INTERVAL", acme.DefaultPollingInterval), + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface. type DNSProvider struct { + config *Config client *api.Client } @@ -24,23 +44,42 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("SAKURACLOUD_ACCESS_TOKEN", "SAKURACLOUD_ACCESS_TOKEN_SECRET") if err != nil { - return nil, fmt.Errorf("SakuraCloud: %v", err) + return nil, fmt.Errorf("sakuracloud: %v", err) } - return NewDNSProviderCredentials(values["SAKURACLOUD_ACCESS_TOKEN"], values["SAKURACLOUD_ACCESS_TOKEN_SECRET"]) + config := NewDefaultConfig() + config.Token = values["SAKURACLOUD_ACCESS_TOKEN"] + config.Secret = values["SAKURACLOUD_ACCESS_TOKEN_SECRET"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for sakuracloud. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for sakuracloud. +// Deprecated func NewDNSProviderCredentials(token, secret string) (*DNSProvider, error) { - if token == "" { - return nil, errors.New("SakuraCloud AccessToken is missing") - } - if secret == "" { - return nil, errors.New("SakuraCloud AccessSecret is missing") + config := NewDefaultConfig() + config.Token = token + config.Secret = secret + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for GleSYS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("sakuracloud: the configuration of the DNS provider is nil") } - client := api.NewClient(token, secret, "tk1a") + if config.Token == "" { + return nil, errors.New("sakuracloud: AccessToken is missing") + } + + if config.Secret == "" { + return nil, errors.New("sakuracloud: AccessSecret is missing") + } + + client := api.NewClient(config.Token, config.Secret, "tk1a") client.UserAgent = acme.UserAgent return &DNSProvider{client: client}, nil @@ -48,19 +87,19 @@ func NewDNSProviderCredentials(token, secret string) (*DNSProvider, error) { // Present creates a TXT record to fulfil the dns-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) zone, err := d.getHostedZone(domain) if err != nil { - return err + return fmt.Errorf("sakuracloud: %v", err) } name := d.extractRecordName(fqdn, zone.Name) - zone.AddRecord(zone.CreateNewRecord(name, "TXT", value, ttl)) + zone.AddRecord(zone.CreateNewRecord(name, "TXT", value, d.config.TTL)) _, err = d.client.GetDNSAPI().Update(zone.ID, zone) if err != nil { - return fmt.Errorf("SakuraCloud API call failed: %v", err) + return fmt.Errorf("sakuracloud: API call failed: %v", err) } return nil @@ -72,12 +111,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { zone, err := d.getHostedZone(domain) if err != nil { - return err + return fmt.Errorf("sakuracloud: %v", err) } records, err := d.findTxtRecords(fqdn, zone) if err != nil { - return err + return fmt.Errorf("sakuracloud: %v", err) } for _, record := range records { @@ -92,12 +131,18 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { _, err = d.client.GetDNSAPI().Update(zone.ID, zone) if err != nil { - return fmt.Errorf("SakuraCloud API call failed: %v", err) + return fmt.Errorf("sakuracloud: API call failed: %v", err) } return nil } +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + func (d *DNSProvider) getHostedZone(domain string) (*sacloud.DNS, error) { authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) if err != nil { @@ -111,7 +156,7 @@ func (d *DNSProvider) getHostedZone(domain string) (*sacloud.DNS, error) { if notFound, ok := err.(api.Error); ok && notFound.ResponseCode() == http.StatusNotFound { return nil, fmt.Errorf("zone %s not found on SakuraCloud DNS: %v", zoneName, err) } - return nil, fmt.Errorf("SakuraCloud API call failed: %v", err) + return nil, fmt.Errorf("API call failed: %v", err) } for _, zone := range res.CommonServiceDNSItems { @@ -120,7 +165,7 @@ func (d *DNSProvider) getHostedZone(domain string) (*sacloud.DNS, error) { } } - return nil, fmt.Errorf("zone %s not found on SakuraCloud DNS", zoneName) + return nil, fmt.Errorf("zone %s not found", zoneName) } func (d *DNSProvider) findTxtRecords(fqdn string, zone *sacloud.DNS) ([]sacloud.DNSRecordSet, error) { diff --git a/providers/dns/sakuracloud/sakuracloud_test.go b/providers/dns/sakuracloud/sakuracloud_test.go index 5ca055c4..3c00baa0 100644 --- a/providers/dns/sakuracloud/sakuracloud_test.go +++ b/providers/dns/sakuracloud/sakuracloud_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/xenolf/lego/acme" ) @@ -54,7 +55,7 @@ func TestNewDNSProviderInvalidWithMissingAccessToken(t *testing.T) { provider, err := NewDNSProvider() assert.Nil(t, provider) - assert.EqualError(t, err, "SakuraCloud: some credentials information are missing: SAKURACLOUD_ACCESS_TOKEN,SAKURACLOUD_ACCESS_TOKEN_SECRET") + assert.EqualError(t, err, "sakuracloud: some credentials information are missing: SAKURACLOUD_ACCESS_TOKEN,SAKURACLOUD_ACCESS_TOKEN_SECRET") } // @@ -62,18 +63,23 @@ func TestNewDNSProviderInvalidWithMissingAccessToken(t *testing.T) { // func TestNewDNSProviderCredentialsValid(t *testing.T) { - provider, err := NewDNSProviderCredentials("123", "456") + config := NewDefaultConfig() + config.Token = "123" + config.Secret = "456" + + provider, err := NewDNSProviderConfig(config) + require.NoError(t, err) assert.NotNil(t, provider) assert.Equal(t, acme.UserAgent, provider.client.UserAgent) - assert.NoError(t, err) } func TestNewDNSProviderCredentialsInvalidWithMissingAccessToken(t *testing.T) { - provider, err := NewDNSProviderCredentials("", "") + config := NewDefaultConfig() + provider, err := NewDNSProviderConfig(config) assert.Nil(t, provider) - assert.EqualError(t, err, "SakuraCloud AccessToken is missing") + assert.EqualError(t, err, "sakuracloud: AccessToken is missing") } // @@ -85,7 +91,11 @@ func TestLiveSakuraCloudPresent(t *testing.T) { t.Skip("skipping live test") } - provider, err := NewDNSProviderCredentials(sakuracloudAccessToken, sakuracloudAccessSecret) + config := NewDefaultConfig() + config.Token = sakuracloudAccessToken + config.Secret = sakuracloudAccessSecret + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.Present(sakuracloudDomain, "", "123d==") @@ -103,7 +113,11 @@ func TestLiveSakuraCloudCleanUp(t *testing.T) { time.Sleep(time.Second * 1) - provider, err := NewDNSProviderCredentials(sakuracloudAccessToken, sakuracloudAccessSecret) + config := NewDefaultConfig() + config.Token = sakuracloudAccessToken + config.Secret = sakuracloudAccessSecret + + provider, err := NewDNSProviderConfig(config) assert.NoError(t, err) err = provider.CleanUp(sakuracloudDomain, "", "123d==") diff --git a/providers/dns/vegadns/vegadns.go b/providers/dns/vegadns/vegadns.go index 4d5371b8..034d5ed4 100644 --- a/providers/dns/vegadns/vegadns.go +++ b/providers/dns/vegadns/vegadns.go @@ -3,6 +3,7 @@ package vegadns import ( + "errors" "fmt" "os" "strings" @@ -13,8 +14,28 @@ import ( "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + BaseURL string + APIKey string + APISecret string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("VEGADNS_TTL", 10), + PropagationTimeout: env.GetOrDefaultSecond("VEGADNS_PROPAGATION_TIMEOUT", 12*time.Minute), + PollingInterval: env.GetOrDefaultSecond("VEGADNS_POLLING_INTERVAL", 1*time.Minute), + } +} + // DNSProvider describes a provider for VegaDNS type DNSProvider struct { + config *Config client vegaClient.VegaDNSClient } @@ -24,62 +45,83 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("VEGADNS_URL") if err != nil { - return nil, fmt.Errorf("VegaDNS: %v", err) + return nil, fmt.Errorf("vegadns: %v", err) } - key := os.Getenv("SECRET_VEGADNS_KEY") - secret := os.Getenv("SECRET_VEGADNS_SECRET") + config := NewDefaultConfig() + config.BaseURL = values["VEGADNS_URL"] + config.APIKey = os.Getenv("SECRET_VEGADNS_KEY") + config.APISecret = os.Getenv("SECRET_VEGADNS_SECRET") - return NewDNSProviderCredentials(values["VEGADNS_URL"], key, secret) + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a -// DNSProvider instance configured for VegaDNS. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for VegaDNS. +// Deprecated func NewDNSProviderCredentials(vegaDNSURL string, key string, secret string) (*DNSProvider, error) { - vega := vegaClient.NewVegaDNSClient(vegaDNSURL) - vega.APIKey = key - vega.APISecret = secret + config := NewDefaultConfig() + config.BaseURL = vegaDNSURL + config.APIKey = key + config.APISecret = secret - return &DNSProvider{ - client: vega, - }, nil + return NewDNSProviderConfig(config) } -// Timeout returns the timeout and interval to use when checking for DNS -// propagation. Adjusting here to cope with spikes in propagation times. -func (r *DNSProvider) Timeout() (timeout, interval time.Duration) { - timeout = 12 * time.Minute - interval = 1 * time.Minute - return +// NewDNSProviderConfig return a DNSProvider instance configured for VegaDNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("vegadns: the configuration of the DNS provider is nil") + } + + vega := vegaClient.NewVegaDNSClient(config.BaseURL) + vega.APIKey = config.APIKey + vega.APISecret = config.APISecret + + return &DNSProvider{client: vega, config: config}, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfil the dns-01 challenge -func (r *DNSProvider) Present(domain, token, keyAuth string) error { +func (d *DNSProvider) Present(domain, token, keyAuth string) error { fqdn, value, _ := acme.DNS01Record(domain, keyAuth) - _, domainID, err := r.client.GetAuthZone(fqdn) + _, domainID, err := d.client.GetAuthZone(fqdn) if err != nil { - return fmt.Errorf("can't find Authoritative Zone for %s in Present: %v", fqdn, err) + return fmt.Errorf("vegadns: can't find Authoritative Zone for %s in Present: %v", fqdn, err) } - return r.client.CreateTXT(domainID, fqdn, value, 10) + err = d.client.CreateTXT(domainID, fqdn, value, d.config.TTL) + if err != nil { + return fmt.Errorf("vegadns: %v", err) + } + return nil } // CleanUp removes the TXT record matching the specified parameters -func (r *DNSProvider) CleanUp(domain, token, keyAuth string) error { +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { fqdn, _, _ := acme.DNS01Record(domain, keyAuth) - _, domainID, err := r.client.GetAuthZone(fqdn) + _, domainID, err := d.client.GetAuthZone(fqdn) if err != nil { - return fmt.Errorf("can't find Authoritative Zone for %s in CleanUp: %v", fqdn, err) + return fmt.Errorf("vegadns: can't find Authoritative Zone for %s in CleanUp: %v", fqdn, err) } txt := strings.TrimSuffix(fqdn, ".") - recordID, err := r.client.GetRecordID(domainID, txt, "TXT") + recordID, err := d.client.GetRecordID(domainID, txt, "TXT") if err != nil { - return fmt.Errorf("couldn't get Record ID in CleanUp: %s", err) + return fmt.Errorf("vegadns: couldn't get Record ID in CleanUp: %s", err) } - return r.client.DeleteRecord(recordID) + err = d.client.DeleteRecord(recordID) + if err != nil { + return fmt.Errorf("vegadns: %v", err) + } + return nil } diff --git a/providers/dns/vegadns/vegadns_test.go b/providers/dns/vegadns/vegadns_test.go index 154771e8..a505801f 100644 --- a/providers/dns/vegadns/vegadns_test.go +++ b/providers/dns/vegadns/vegadns_test.go @@ -147,7 +147,7 @@ func TestVegaDNSPresentFailToFindZone(t *testing.T) { require.NoError(t, err) err = provider.Present("example.com", "token", "keyAuth") - assert.EqualError(t, err, "can't find Authoritative Zone for _acme-challenge.example.com. in Present: Unable to find auth zone for fqdn _acme-challenge.example.com") + assert.EqualError(t, err, "vegadns: can't find Authoritative Zone for _acme-challenge.example.com. in Present: Unable to find auth zone for fqdn _acme-challenge.example.com") } func TestVegaDNSPresentFailToCreateTXT(t *testing.T) { @@ -161,7 +161,7 @@ func TestVegaDNSPresentFailToCreateTXT(t *testing.T) { require.NoError(t, err) err = provider.Present("example.com", "token", "keyAuth") - assert.EqualError(t, err, "Got bad answer from VegaDNS on CreateTXT. Code: 400. Message: ") + assert.EqualError(t, err, "vegadns: Got bad answer from VegaDNS on CreateTXT. Code: 400. Message: ") } func TestVegaDNSCleanUpSuccess(t *testing.T) { @@ -189,7 +189,7 @@ func TestVegaDNSCleanUpFailToFindZone(t *testing.T) { require.NoError(t, err) err = provider.CleanUp("example.com", "token", "keyAuth") - assert.EqualError(t, err, "can't find Authoritative Zone for _acme-challenge.example.com. in CleanUp: Unable to find auth zone for fqdn _acme-challenge.example.com") + assert.EqualError(t, err, "vegadns: can't find Authoritative Zone for _acme-challenge.example.com. in CleanUp: Unable to find auth zone for fqdn _acme-challenge.example.com") } func TestVegaDNSCleanUpFailToGetRecordID(t *testing.T) { @@ -203,7 +203,7 @@ func TestVegaDNSCleanUpFailToGetRecordID(t *testing.T) { require.NoError(t, err) err = provider.CleanUp("example.com", "token", "keyAuth") - assert.EqualError(t, err, "couldn't get Record ID in CleanUp: Got bad answer from VegaDNS on GetRecordID. Code: 404. Message: ") + assert.EqualError(t, err, "vegadns: couldn't get Record ID in CleanUp: Got bad answer from VegaDNS on GetRecordID. Code: 404. Message: ") } func vegaDNSMuxSuccess() *http.ServeMux { diff --git a/providers/dns/vultr/vultr.go b/providers/dns/vultr/vultr.go index 27cfa524..e6a2a336 100644 --- a/providers/dns/vultr/vultr.go +++ b/providers/dns/vultr/vultr.go @@ -4,16 +4,46 @@ package vultr import ( + "crypto/tls" + "errors" "fmt" + "net/http" "strings" + "time" vultr "github.com/JamesClonk/vultr/lib" "github.com/xenolf/lego/acme" "github.com/xenolf/lego/platform/config/env" ) +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("VULTR_TTL", 120), + PropagationTimeout: env.GetOrDefaultSecond("VULTR_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("VULTR_POLLING_INTERVAL", acme.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("VULTR_HTTP_TIMEOUT", 0), + // from Vultr Client + Transport: &http.Transport{ + TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), + }, + }, + } +} + // DNSProvider is an implementation of the acme.ChallengeProvider interface. type DNSProvider struct { + config *Config client *vultr.Client } @@ -22,36 +52,58 @@ type DNSProvider struct { func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get("VULTR_API_KEY") if err != nil { - return nil, fmt.Errorf("Vultr: %v", err) + return nil, fmt.Errorf("vultr: %v", err) } - return NewDNSProviderCredentials(values["VULTR_API_KEY"]) + config := NewDefaultConfig() + config.APIKey = values["VULTR_API_KEY"] + + return NewDNSProviderConfig(config) } -// NewDNSProviderCredentials uses the supplied credentials to return a DNSProvider -// instance configured for Vultr. +// NewDNSProviderCredentials uses the supplied credentials +// to return a DNSProvider instance configured for Vultr. +// Deprecated func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { - if apiKey == "" { - return nil, fmt.Errorf("Vultr credentials missing") + config := NewDefaultConfig() + config.APIKey = apiKey + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Vultr. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("vultr: the configuration of the DNS provider is nil") } - return &DNSProvider{client: vultr.NewClient(apiKey, nil)}, nil + if config.APIKey == "" { + return nil, fmt.Errorf("vultr: credentials missing") + } + + options := &vultr.Options{ + HTTPClient: config.HTTPClient, + UserAgent: acme.UserAgent, + } + client := vultr.NewClient(config.APIKey, options) + + return &DNSProvider{client: client, config: config}, nil } // Present creates a TXT record to fulfil the DNS-01 challenge. func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) zoneDomain, err := d.getHostedZone(domain) if err != nil { - return err + return fmt.Errorf("vultr: %v", err) } name := d.extractRecordName(fqdn, zoneDomain) - err = d.client.CreateDNSRecord(zoneDomain, name, "TXT", `"`+value+`"`, 0, ttl) + err = d.client.CreateDNSRecord(zoneDomain, name, "TXT", `"`+value+`"`, 0, d.config.TTL) if err != nil { - return fmt.Errorf("Vultr API call failed: %v", err) + return fmt.Errorf("vultr: API call failed: %v", err) } return nil @@ -63,22 +115,34 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { zoneDomain, records, err := d.findTxtRecords(domain, fqdn) if err != nil { - return err + return fmt.Errorf("vultr: %v", err) } + var allErr []string for _, rec := range records { err := d.client.DeleteDNSRecord(zoneDomain, rec.RecordID) if err != nil { - return err + allErr = append(allErr, err.Error()) } } + + if len(allErr) > 0 { + return errors.New(strings.Join(allErr, ": ")) + } + return nil } +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + func (d *DNSProvider) getHostedZone(domain string) (string, error) { domains, err := d.client.GetDNSDomains() if err != nil { - return "", fmt.Errorf("Vultr API call failed: %v", err) + return "", fmt.Errorf("API call failed: %v", err) } var hostedDomain vultr.DNSDomain @@ -90,7 +154,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) { } } if hostedDomain.Domain == "" { - return "", fmt.Errorf("No matching Vultr domain found for domain %s", domain) + return "", fmt.Errorf("no matching Vultr domain found for domain %s", domain) } return hostedDomain.Domain, nil @@ -105,7 +169,7 @@ func (d *DNSProvider) findTxtRecords(domain, fqdn string) (string, []vultr.DNSRe var records []vultr.DNSRecord result, err := d.client.GetDNSRecords(zoneDomain) if err != nil { - return "", records, fmt.Errorf("Vultr API call has failed: %v", err) + return "", records, fmt.Errorf("API call has failed: %v", err) } recordName := d.extractRecordName(fqdn, zoneDomain) diff --git a/providers/dns/vultr/vultr_test.go b/providers/dns/vultr/vultr_test.go index e058065d..4c9b8a37 100644 --- a/providers/dns/vultr/vultr_test.go +++ b/providers/dns/vultr/vultr_test.go @@ -37,7 +37,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) { os.Setenv("VULTR_API_KEY", "") _, err := NewDNSProvider() - assert.EqualError(t, err, "Vultr: some credentials information are missing: VULTR_API_KEY") + assert.EqualError(t, err, "vultr: some credentials information are missing: VULTR_API_KEY") } func TestLivePresent(t *testing.T) {