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).
This commit is contained in:
Ludovic Fernandez 2018-09-15 19:07:24 +02:00 committed by GitHub
parent ad34a85dad
commit bba134ce87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
82 changed files with 4800 additions and 2521 deletions

2
Gopkg.lock generated
View file

@ -522,6 +522,8 @@
"github.com/OpenDNS/vegadns2client", "github.com/OpenDNS/vegadns2client",
"github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v1", "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v1",
"github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid", "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/sdk/requests",
"github.com/aliyun/alibaba-cloud-sdk-go/services/alidns", "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns",
"github.com/aws/aws-sdk-go/aws", "github.com/aws/aws-sdk-go/aws",

View file

@ -24,6 +24,14 @@ var (
const defaultResolvConf = "/etc/resolv.conf" 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{ var defaultNameservers = []string{
"google-public-dns-a.google.com:53", "google-public-dns-a.google.com:53",
"google-public-dns-b.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: case ChallengeProviderTimeout:
timeout, interval = provider.Timeout() timeout, interval = provider.Timeout()
default: default:
timeout, interval = 60*time.Second, 2*time.Second timeout, interval = DefaultPropagationTimeout, DefaultPollingInterval
} }
err = WaitFor(timeout, interval, func() (bool, error) { err = WaitFor(timeout, interval, func() (bool, error) {

View file

@ -5,6 +5,7 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
) )
// Get environment variables // Get environment variables
@ -37,3 +38,36 @@ func GetOrDefaultInt(envVar string, defaultValue int) int {
return v 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
}

View file

@ -3,12 +3,13 @@ package env
import ( import (
"os" "os"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func Test_GetOrDefaultInt(t *testing.T) { func TestGetOrDefaultInt(t *testing.T) {
testCases := []struct { testCases := []struct {
desc string desc string
envValue 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)
})
}
}

View file

@ -3,10 +3,14 @@
package alidns package alidns
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"strings" "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/sdk/requests"
"github.com/aliyun/alibaba-cloud-sdk-go/services/alidns" "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
@ -15,8 +19,30 @@ import (
const defaultRegionID = "cn-hangzhou" 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 // DNSProvider is an implementation of the acme.ChallengeProvider interface
type DNSProvider struct { type DNSProvider struct {
config *Config
client *alidns.Client client *alidns.Client
} }
@ -25,48 +51,74 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("ALICLOUD_ACCESS_KEY", "ALICLOUD_SECRET_KEY") values, err := env.Get("ALICLOUD_ACCESS_KEY", "ALICLOUD_SECRET_KEY")
if err != nil { 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) { func NewDNSProviderCredentials(apiKey, secretKey, regionID string) (*DNSProvider, error) {
if apiKey == "" || secretKey == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("AliDNS: credentials missing") 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 { if config.APIKey == "" || config.SecretKey == "" {
regionID = defaultRegionID 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 { if err != nil {
return nil, fmt.Errorf("AliDNS: credentials failed: %v", err) return nil, fmt.Errorf("alicloud: credentials failed: %v", err)
} }
return &DNSProvider{ return &DNSProvider{config: config, client: client}, nil
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. // Present creates a TXT record to fulfil the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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) _, zoneName, err := d.getHostedZone(domain)
if err != nil { 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) _, err = d.client.AddDomainRecord(recordAttributes)
if err != nil { if err != nil {
return fmt.Errorf("AliDNS: API call failed: %v", err) return fmt.Errorf("alicloud: API call failed: %v", err)
} }
return nil return nil
} }
@ -77,12 +129,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
records, err := d.findTxtRecords(domain, fqdn) records, err := d.findTxtRecords(domain, fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("alicloud: %v", err)
} }
_, _, err = d.getHostedZone(domain) _, _, err = d.getHostedZone(domain)
if err != nil { if err != nil {
return err return fmt.Errorf("alicloud: %v", err)
} }
for _, rec := range records { for _, rec := range records {
@ -90,7 +142,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
request.RecordId = rec.RecordId request.RecordId = rec.RecordId
_, err = d.client.DeleteDomainRecord(request) _, err = d.client.DeleteDomainRecord(request)
if err != nil { if err != nil {
return err return fmt.Errorf("alicloud: %v", err)
} }
} }
return nil return nil
@ -100,7 +152,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, string, error) {
request := alidns.CreateDescribeDomainsRequest() request := alidns.CreateDescribeDomainsRequest()
zones, err := d.client.DescribeDomains(request) zones, err := d.client.DescribeDomains(request)
if err != nil { 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) 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 == "" { 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 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 := alidns.CreateAddDomainRecordRequest()
request.Type = "TXT" request.Type = "TXT"
request.DomainName = zone request.DomainName = zone
request.RR = d.extractRecordName(fqdn, zone) request.RR = d.extractRecordName(fqdn, zone)
request.Value = value request.Value = value
request.TTL = requests.NewInteger(600) request.TTL = requests.NewInteger(d.config.TTL)
return request return request
} }
@ -145,7 +197,7 @@ func (d *DNSProvider) findTxtRecords(domain, fqdn string) ([]alidns.Record, erro
result, err := d.client.DescribeDomainRecords(request) result, err := d.client.DescribeDomainRecords(request)
if err != nil { 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) recordName := d.extractRecordName(fqdn, zoneName)

View file

@ -35,7 +35,11 @@ func TestNewDNSProviderValid(t *testing.T) {
os.Setenv("ALICLOUD_ACCESS_KEY", "") os.Setenv("ALICLOUD_ACCESS_KEY", "")
os.Setenv("ALICLOUD_SECRET_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) assert.NoError(t, err)
} }
@ -54,7 +58,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("ALICLOUD_SECRET_KEY", "") os.Setenv("ALICLOUD_SECRET_KEY", "")
_, err := NewDNSProvider() _, 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) { func TestCloudXNSPresent(t *testing.T) {
@ -62,7 +66,11 @@ func TestCloudXNSPresent(t *testing.T) {
t.Skip("skipping live test") 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) assert.NoError(t, err)
err = provider.Present(alidnsDomain, "", "123d==") err = provider.Present(alidnsDomain, "", "123d==")
@ -75,7 +83,12 @@ func TestLivednspodCleanUp(t *testing.T) {
} }
time.Sleep(time.Second * 1) 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) assert.NoError(t, err)
err = provider.CleanUp(alidnsDomain, "", "123d==") err = provider.CleanUp(alidnsDomain, "", "123d==")
assert.NoError(t, err) assert.NoError(t, err)

View file

@ -1,9 +1,11 @@
package auroradns package auroradns
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"sync" "sync"
"time"
"github.com/edeckers/auroradnsclient" "github.com/edeckers/auroradnsclient"
"github.com/edeckers/auroradnsclient/records" "github.com/edeckers/auroradnsclient/records"
@ -12,68 +14,97 @@ import (
"github.com/xenolf/lego/platform/config/env" "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 // DNSProvider describes a provider for AuroraDNS
type DNSProvider struct { type DNSProvider struct {
recordIDs map[string]string recordIDs map[string]string
recordIDsMu sync.Mutex recordIDsMu sync.Mutex
config *Config
client *auroradnsclient.AuroraDNSClient client *auroradnsclient.AuroraDNSClient
} }
// NewDNSProvider returns a DNSProvider instance configured for AuroraDNS. // NewDNSProvider returns a DNSProvider instance configured for AuroraDNS.
// Credentials must be passed in the environment variables: AURORA_USER_ID // Credentials must be passed in the environment variables:
// and AURORA_KEY. // AURORA_USER_ID and AURORA_KEY.
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("AURORA_USER_ID", "AURORA_KEY") values, err := env.Get("AURORA_USER_ID", "AURORA_KEY")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for AuroraDNS. // to return a DNSProvider instance configured for AuroraDNS.
// Deprecated
func NewDNSProviderCredentials(baseURL string, userID string, key string) (*DNSProvider, error) { func NewDNSProviderCredentials(baseURL string, userID string, key string) (*DNSProvider, error) {
if baseURL == "" { config := NewDefaultConfig()
baseURL = "https://api.auroradns.eu" 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 { if err != nil {
return nil, err return nil, fmt.Errorf("aurora: %v", err)
} }
return &DNSProvider{ return &DNSProvider{
config: config,
client: client, client: client,
recordIDs: make(map[string]string), recordIDs: make(map[string]string),
}, nil }, 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 // Present creates a record with a secret
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, _ := acme.DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers)
if err != nil { 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, // 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) zoneRecord, err := d.getZoneInformationByName(authZone)
if err != nil { if err != nil {
return fmt.Errorf("could not create record: %v", err) return fmt.Errorf("aurora: could not create record: %v", err)
} }
reqData := reqData :=
@ -97,12 +128,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
RecordType: "TXT", RecordType: "TXT",
Name: subdomain, Name: subdomain,
Content: value, Content: value,
TTL: 300, TTL: d.config.TTL,
} }
respData, err := d.client.CreateRecord(zoneRecord.ID, reqData) respData, err := d.client.CreateRecord(zoneRecord.ID, reqData)
if err != nil { 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() d.recordIDsMu.Lock()
@ -147,3 +178,24 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil 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")
}

View file

@ -48,11 +48,16 @@ func TestAuroraDNSPresent(t *testing.T) {
defer mock.Close() defer mock.Close()
auroraProvider, err := NewDNSProviderCredentials(mock.URL, fakeAuroraDNSUserID, fakeAuroraDNSKey) config := NewDefaultConfig()
require.NoError(t, err) config.UserID = fakeAuroraDNSUserID
require.NotNil(t, auroraProvider) 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") 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") 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() defer mock.Close()
auroraProvider, err := NewDNSProviderCredentials(mock.URL, fakeAuroraDNSUserID, fakeAuroraDNSKey) config := NewDefaultConfig()
require.NoError(t, err) config.UserID = fakeAuroraDNSUserID
require.NotNil(t, auroraProvider) 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") 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") 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") assert.True(t, requestReceived, "Expected request to be received by mock backend, but it wasn't")

View file

@ -7,6 +7,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"strings" "strings"
"time" "time"
@ -19,14 +20,31 @@ import (
"github.com/xenolf/lego/platform/config/env" "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 // DNSProvider is an implementation of the acme.ChallengeProvider interface
type DNSProvider struct { type DNSProvider struct {
clientID string config *Config
clientSecret string
subscriptionID string
tenantID string
resourceGroup string
context context.Context
} }
// NewDNSProvider returns a DNSProvider instance configured for azure. // NewDNSProvider returns a DNSProvider instance configured for azure.
@ -35,54 +53,66 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET", "AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID", "AZURE_RESOURCE_GROUP") values, err := env.Get("AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET", "AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID", "AZURE_RESOURCE_GROUP")
if err != nil { if err != nil {
return nil, fmt.Errorf("Azure: %v", err) return nil, fmt.Errorf("azure: %v", err)
} }
return NewDNSProviderCredentials( config := NewDefaultConfig()
values["AZURE_CLIENT_ID"], config.ClientID = values["AZURE_CLIENT_ID"]
values["AZURE_CLIENT_SECRET"], config.ClientSecret = values["AZURE_CLIENT_SECRET"]
values["AZURE_SUBSCRIPTION_ID"], config.SubscriptionID = values["AZURE_SUBSCRIPTION_ID"]
values["AZURE_TENANT_ID"], config.TenantID = values["AZURE_TENANT_ID"]
values["AZURE_RESOURCE_GROUP"], config.ResourceGroup = values["AZURE_RESOURCE_GROUP"]
)
return NewDNSProviderConfig(config)
} }
// NewDNSProviderCredentials uses the supplied credentials to return a // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for azure. // to return a DNSProvider instance configured for azure.
// Deprecated
func NewDNSProviderCredentials(clientID, clientSecret, subscriptionID, tenantID, resourceGroup string) (*DNSProvider, error) { func NewDNSProviderCredentials(clientID, clientSecret, subscriptionID, tenantID, resourceGroup string) (*DNSProvider, error) {
if clientID == "" || clientSecret == "" || subscriptionID == "" || tenantID == "" || resourceGroup == "" { config := NewDefaultConfig()
return nil, errors.New("Azure: some credentials information are missing") 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{ if config.ClientID == "" || config.ClientSecret == "" || config.SubscriptionID == "" || config.TenantID == "" || config.ResourceGroup == "" {
clientID: clientID, return nil, errors.New("azure: some credentials information are missing")
clientSecret: clientSecret, }
subscriptionID: subscriptionID,
tenantID: tenantID, return &DNSProvider{config: config}, nil
resourceGroup: resourceGroup,
// TODO: A timeout can be added here for cancellation purposes.
context: context.Background(),
}, nil
} }
// Timeout returns the timeout and interval to use when checking for DNS // Timeout returns the timeout and interval to use when checking for DNS
// propagation. Adjusting here to cope with spikes in propagation times. // propagation. Adjusting here to cope with spikes in propagation times.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { 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 // Present creates a TXT record to fulfil the dns-01 challenge
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
ctx := context.Background()
fqdn, value, _ := acme.DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
zone, err := d.getHostedZoneID(fqdn)
zone, err := d.getHostedZoneID(ctx, fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("azure: %v", err)
} }
rsc := dns.NewRecordSetsClient(d.subscriptionID) rsc := dns.NewRecordSetsClient(d.config.SubscriptionID)
spt, err := d.newServicePrincipalTokenFromCredentials(azure.PublicCloud.ResourceManagerEndpoint) spt, err := d.newServicePrincipalToken(azure.PublicCloud.ResourceManagerEndpoint)
if err != nil { if err != nil {
return err return fmt.Errorf("azure: %v", err)
} }
rsc.Authorizer = autorest.NewBearerAuthorizer(spt) rsc.Authorizer = autorest.NewBearerAuthorizer(spt)
@ -91,59 +121,55 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
rec := dns.RecordSet{ rec := dns.RecordSet{
Name: &relative, Name: &relative,
RecordSetProperties: &dns.RecordSetProperties{ RecordSetProperties: &dns.RecordSetProperties{
TTL: to.Int64Ptr(60), TTL: to.Int64Ptr(int64(d.config.TTL)),
TxtRecords: &[]dns.TxtRecord{{Value: &[]string{value}}}, TxtRecords: &[]dns.TxtRecord{{Value: &[]string{value}}},
}, },
} }
_, err = rsc.CreateOrUpdate(d.context, d.resourceGroup, zone, relative, dns.TXT, rec, "", "") _, err = rsc.CreateOrUpdate(ctx, d.config.ResourceGroup, zone, relative, dns.TXT, rec, "", "")
return err return fmt.Errorf("azure: %v", err)
}
// Returns the relative record to the domain
func toRelativeRecord(domain, zone string) string {
return acme.UnFqdn(strings.TrimSuffix(domain, zone))
} }
// CleanUp removes the TXT record matching the specified parameters // CleanUp removes the TXT record matching the specified parameters
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
ctx := context.Background()
fqdn, _, _ := acme.DNS01Record(domain, keyAuth) fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
zone, err := d.getHostedZoneID(fqdn) zone, err := d.getHostedZoneID(ctx, fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("azure: %v", err)
} }
relative := toRelativeRecord(fqdn, acme.ToFqdn(zone)) relative := toRelativeRecord(fqdn, acme.ToFqdn(zone))
rsc := dns.NewRecordSetsClient(d.subscriptionID) rsc := dns.NewRecordSetsClient(d.config.SubscriptionID)
spt, err := d.newServicePrincipalTokenFromCredentials(azure.PublicCloud.ResourceManagerEndpoint) spt, err := d.newServicePrincipalToken(azure.PublicCloud.ResourceManagerEndpoint)
if err != nil { if err != nil {
return err return fmt.Errorf("azure: %v", err)
} }
rsc.Authorizer = autorest.NewBearerAuthorizer(spt) rsc.Authorizer = autorest.NewBearerAuthorizer(spt)
_, err = rsc.Delete(d.context, d.resourceGroup, zone, relative, dns.TXT, "") _, err = rsc.Delete(ctx, d.config.ResourceGroup, zone, relative, dns.TXT, "")
return err return fmt.Errorf("azure: %v", err)
} }
// Checks that azure has a zone for this domain name. // 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) authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers)
if err != nil { if err != nil {
return "", err return "", err
} }
// Now we want to to Azure and get the zone. // 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 { if err != nil {
return "", err return "", err
} }
dc := dns.NewZonesClient(d.subscriptionID) dc := dns.NewZonesClient(d.config.SubscriptionID)
dc.Authorizer = autorest.NewBearerAuthorizer(spt) 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 { if err != nil {
return "", err return "", err
} }
@ -154,10 +180,15 @@ func (d *DNSProvider) getHostedZoneID(fqdn string) (string, error) {
// NewServicePrincipalTokenFromCredentials creates a new ServicePrincipalToken using values of the // NewServicePrincipalTokenFromCredentials creates a new ServicePrincipalToken using values of the
// passed credentials map. // passed credentials map.
func (d *DNSProvider) newServicePrincipalTokenFromCredentials(scope string) (*adal.ServicePrincipalToken, error) { func (d *DNSProvider) newServicePrincipalToken(scope string) (*adal.ServicePrincipalToken, error) {
oauthConfig, err := adal.NewOAuthConfig(azure.PublicCloud.ActiveDirectoryEndpoint, d.tenantID) oauthConfig, err := adal.NewOAuthConfig(azure.PublicCloud.ActiveDirectoryEndpoint, d.config.TenantID)
if err != nil { if err != nil {
return nil, err 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))
} }

View file

@ -43,7 +43,14 @@ func TestNewDNSProviderValid(t *testing.T) {
defer restoreEnv() defer restoreEnv()
os.Setenv("AZURE_CLIENT_ID", "") 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) assert.NoError(t, err)
} }
@ -64,7 +71,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("AZURE_SUBSCRIPTION_ID", "") os.Setenv("AZURE_SUBSCRIPTION_ID", "")
_, err := NewDNSProvider() _, 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) { func TestLiveAzurePresent(t *testing.T) {
@ -72,7 +79,14 @@ func TestLiveAzurePresent(t *testing.T) {
t.Skip("skipping live test") 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) assert.NoError(t, err)
err = provider.Present(azureDomain, "", "123d==") err = provider.Present(azureDomain, "", "123d==")
@ -84,7 +98,15 @@ func TestLiveAzureCleanUp(t *testing.T) {
t.Skip("skipping live test") 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) time.Sleep(time.Second * 1)
assert.NoError(t, err) assert.NoError(t, err)

View file

@ -5,6 +5,7 @@ package bluecat
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
@ -17,91 +18,221 @@ import (
"github.com/xenolf/lego/platform/config/env" "github.com/xenolf/lego/platform/config/env"
) )
const bluecatURLTemplate = "%s/Services/REST/v1"
const configType = "Configuration" const configType = "Configuration"
const viewType = "View" const viewType = "View"
const txtType = "TXTRecord" const txtType = "TXTRecord"
const zoneType = "Zone" const zoneType = "Zone"
type entityResponse struct { // Config is used to configure the creation of the DNSProvider
ID uint `json:"id"` type Config struct {
Name string `json:"name"` BaseURL string
Type string `json:"type"` UserName string
Properties string `json:"properties"` 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 // DNSProvider is an implementation of the acme.ChallengeProvider interface that uses
// Bluecat's Address Manager REST API to manage TXT records for a domain. // Bluecat's Address Manager REST API to manage TXT records for a domain.
type DNSProvider struct { type DNSProvider struct {
baseURL string config *Config
userName string
password string
configName string
dnsView string
token string token string
client *http.Client
} }
// NewDNSProvider returns a DNSProvider instance configured for Bluecat DNS. // NewDNSProvider returns a DNSProvider instance configured for Bluecat DNS.
// Credentials must be passed in the environment variables: BLUECAT_SERVER_URL, // Credentials must be passed in the environment variables: BLUECAT_SERVER_URL, BLUECAT_USER_NAME and BLUECAT_PASSWORD.
// BLUECAT_USER_NAME and BLUECAT_PASSWORD. BLUECAT_SERVER_URL should have the // BLUECAT_SERVER_URL should have the scheme, hostname, and port (if required) of the authoritative Bluecat BAM server.
// scheme, hostname, and port (if required) of the authoritative Bluecat BAM // The REST endpoint will be appended. In addition, the Configuration name
// 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
// and external DNS View Name must be passed in BLUECAT_CONFIG_NAME and
// BLUECAT_DNS_VIEW
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("BLUECAT_SERVER_URL", "BLUECAT_USER_NAME", "BLUECAT_CONFIG_NAME", "BLUECAT_CONFIG_NAME", "BLUECAT_DNS_VIEW") values, err := env.Get("BLUECAT_SERVER_URL", "BLUECAT_USER_NAME", "BLUECAT_CONFIG_NAME", "BLUECAT_CONFIG_NAME", "BLUECAT_DNS_VIEW")
if err != nil { 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( return NewDNSProviderConfig(config)
values["BLUECAT_SERVER_URL"],
values["BLUECAT_USER_NAME"],
values["BLUECAT_PASSWORD"],
values["BLUECAT_CONFIG_NAME"],
values["BLUECAT_DNS_VIEW"],
httpClient,
)
} }
// NewDNSProviderCredentials uses the supplied credentials to return a // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for Bluecat DNS. // to return a DNSProvider instance configured for Bluecat DNS.
func NewDNSProviderCredentials(server, userName, password, configName, dnsView string, httpClient *http.Client) (*DNSProvider, error) { // Deprecated
if server == "" || userName == "" || password == "" || configName == "" || dnsView == "" { func NewDNSProviderCredentials(baseURL, userName, password, configName, dnsView string, httpClient *http.Client) (*DNSProvider, error) {
return nil, fmt.Errorf("Bluecat credentials missing") config := NewDefaultConfig()
} config.BaseURL = baseURL
config.UserName = userName
config.Password = password
config.ConfigName = configName
config.DNSView = dnsView
client := http.DefaultClient
if httpClient != nil { if httpClient != nil {
client = httpClient config.HTTPClient = httpClient
} }
return &DNSProvider{ return NewDNSProviderConfig(config)
baseURL: fmt.Sprintf(bluecatURLTemplate, server), }
userName: userName,
password: password, // NewDNSProviderConfig return a DNSProvider instance configured for Bluecat DNS.
configName: configName, func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
dnsView: dnsView, if config == nil {
client: client, return nil, errors.New("bluecat: the configuration of the DNS provider is nil")
}, 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 // Send a REST request, using query parameters specified. The Authorization
// header will be set if we have an active auth token // 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) { 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) body, err := json.Marshal(payload)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("bluecat: %v", err)
} }
req, err := http.NewRequest(method, url, bytes.NewReader(body)) req, err := http.NewRequest(method, url, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("bluecat: %v", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if len(d.token) > 0 { if len(d.token) > 0 {
@ -114,15 +245,15 @@ func (d *DNSProvider) sendRequest(method, resource string, payload interface{},
q.Add(argName, argVal) q.Add(argName, argVal)
} }
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
resp, err := d.client.Do(req) resp, err := d.config.HTTPClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("bluecat: %v", err)
} }
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
errBytes, _ := ioutil.ReadAll(resp.Body) errBytes, _ := ioutil.ReadAll(resp.Body)
errResp := string(errBytes) 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) 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. // password and receives a token to be used in for subsequent requests.
func (d *DNSProvider) login() error { func (d *DNSProvider) login() error {
queryArgs := map[string]string{ queryArgs := map[string]string{
"username": d.userName, "username": d.config.UserName,
"password": d.password, "password": d.config.Password,
} }
resp, err := d.sendRequest(http.MethodGet, "login", nil, queryArgs) resp, err := d.sendRequest(http.MethodGet, "login", nil, queryArgs)
@ -145,18 +276,16 @@ func (d *DNSProvider) login() error {
authBytes, err := ioutil.ReadAll(resp.Body) authBytes, err := ioutil.ReadAll(resp.Body)
if err != nil { if err != nil {
return err return fmt.Errorf("bluecat: %v", err)
} }
authResp := string(authBytes) authResp := string(authBytes)
if strings.Contains(authResp, "Authentication Error") { if strings.Contains(authResp, "Authentication Error") {
msg := strings.Trim(authResp, "\"") 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" // Upon success, API responds with "Session Token-> BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM= <- for User : username"
re := regexp.MustCompile("BAMAuthToken: [^ ]+") d.token = regexp.MustCompile("BAMAuthToken: [^ ]+").FindString(authResp)
token := re.FindString(authResp)
d.token = token
return nil return nil
} }
@ -174,7 +303,7 @@ func (d *DNSProvider) logout() error {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { 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) authBytes, err := ioutil.ReadAll(resp.Body)
@ -185,7 +314,7 @@ func (d *DNSProvider) logout() error {
if !strings.Contains(authResp, "successfully") { if !strings.Contains(authResp, "successfully") {
msg := strings.Trim(authResp, "\"") 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 = "" d.token = ""
@ -197,7 +326,7 @@ func (d *DNSProvider) logout() error {
func (d *DNSProvider) lookupConfID() (uint, error) { func (d *DNSProvider) lookupConfID() (uint, error) {
queryArgs := map[string]string{ queryArgs := map[string]string{
"parentId": strconv.Itoa(0), "parentId": strconv.Itoa(0),
"name": d.configName, "name": d.config.ConfigName,
"type": configType, "type": configType,
} }
@ -210,7 +339,7 @@ func (d *DNSProvider) lookupConfID() (uint, error) {
var conf entityResponse var conf entityResponse
err = json.NewDecoder(resp.Body).Decode(&conf) err = json.NewDecoder(resp.Body).Decode(&conf)
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("bluecat: %v", err)
} }
return conf.ID, nil return conf.ID, nil
} }
@ -224,7 +353,7 @@ func (d *DNSProvider) lookupViewID(viewName string) (uint, error) {
queryArgs := map[string]string{ queryArgs := map[string]string{
"parentId": strconv.FormatUint(uint64(confID), 10), "parentId": strconv.FormatUint(uint64(confID), 10),
"name": d.dnsView, "name": d.config.DNSView,
"type": viewType, "type": viewType,
} }
@ -237,7 +366,7 @@ func (d *DNSProvider) lookupViewID(viewName string) (uint, error) {
var view entityResponse var view entityResponse
err = json.NewDecoder(resp.Body).Decode(&view) err = json.NewDecoder(resp.Body).Decode(&view)
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("bluecat: %v", err)
} }
return view.ID, nil 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) resp, err := d.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs)
// Return an empty zone if the named zone doesn't exist // Return an empty zone if the named zone doesn't exist
if resp != nil && resp.StatusCode == 404 { 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 { if err != nil {
return 0, err return 0, err
@ -290,65 +419,12 @@ func (d *DNSProvider) getZone(parentID uint, name string) (uint, error) {
var zone entityResponse var zone entityResponse
err = json.NewDecoder(resp.Body).Decode(&zone) err = json.NewDecoder(resp.Body).Decode(&zone)
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("bluecat: %v", err)
} }
return zone.ID, nil 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 // Deploy the DNS config for the specified entity to the authoritative servers
func (d *DNSProvider) deploy(entityID uint) error { func (d *DNSProvider) deploy(entityID uint) error {
queryArgs := map[string]string{ queryArgs := map[string]string{
@ -363,65 +439,3 @@ func (d *DNSProvider) deploy(entityID uint) error {
return nil 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"`
}

View file

@ -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"`
}

View file

@ -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)
}

View file

@ -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)
}
})
}
}

View file

@ -3,12 +3,8 @@
package cloudflare package cloudflare
import ( import (
"bytes"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"io/ioutil"
"net/http" "net/http"
"time" "time"
@ -17,208 +13,108 @@ import (
) )
// CloudFlareAPIURL represents the API endpoint to call. // CloudFlareAPIURL represents the API endpoint to call.
// TODO: Unexport? const CloudFlareAPIURL = defaultBaseURL // Deprecated
const CloudFlareAPIURL = "https://api.cloudflare.com/client/v4"
// 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 // DNSProvider is an implementation of the acme.ChallengeProvider interface
type DNSProvider struct { type DNSProvider struct {
authEmail string client *Client
authKey string config *Config
client *http.Client
} }
// NewDNSProvider returns a DNSProvider instance configured for cloudflare. // NewDNSProvider returns a DNSProvider instance configured for Cloudflare.
// Credentials must be passed in the environment variables: CLOUDFLARE_EMAIL // Credentials must be passed in the environment variables:
// and CLOUDFLARE_API_KEY. // CLOUDFLARE_EMAIL and CLOUDFLARE_API_KEY.
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("CLOUDFLARE_EMAIL", "CLOUDFLARE_API_KEY") values, err := env.Get("CLOUDFLARE_EMAIL", "CLOUDFLARE_API_KEY")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for cloudflare. // to return a DNSProvider instance configured for Cloudflare.
// Deprecated
func NewDNSProviderCredentials(email, key string) (*DNSProvider, error) { func NewDNSProviderCredentials(email, key string) (*DNSProvider, error) {
if email == "" || key == "" { config := NewDefaultConfig()
return nil, errors.New("CloudFlare: some credentials information are missing") 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{ return &DNSProvider{
authEmail: email, client: client,
authKey: key, config: config,
client: &http.Client{Timeout: 30 * time.Second},
}, nil }, nil
} }
// Timeout returns the timeout and interval to use when checking for DNS // Timeout returns the timeout and interval to use when checking for DNS
// propagation. Adjusting here to cope with spikes in propagation times. // propagation. Adjusting here to cope with spikes in propagation times.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { 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 // Present creates a TXT record to fulfil the dns-01 challenge
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
zoneID, err := d.getHostedZoneID(fqdn)
if err != nil {
return err
}
rec := cloudFlareRecord{ rec := TxtRecord{
Type: "TXT", Type: "TXT",
Name: acme.UnFqdn(fqdn), Name: acme.UnFqdn(fqdn),
Content: value, Content: value,
TTL: ttl, TTL: d.config.TTL,
} }
body, err := json.Marshal(rec) return d.client.AddTxtRecord(fqdn, rec)
if err != nil {
return err
}
_, err = d.doRequest(http.MethodPost, fmt.Sprintf("/zones/%s/dns_records", zoneID), bytes.NewReader(body))
return err
} }
// CleanUp removes the TXT record matching the specified parameters // CleanUp removes the TXT record matching the specified parameters
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := acme.DNS01Record(domain, keyAuth) fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
record, err := d.findTxtRecord(fqdn) return d.client.RemoveTxtRecord(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"`
} }

View file

@ -34,7 +34,11 @@ func TestNewDNSProviderValid(t *testing.T) {
os.Setenv("CLOUDFLARE_API_KEY", "") os.Setenv("CLOUDFLARE_API_KEY", "")
defer restoreEnv() defer restoreEnv()
_, err := NewDNSProviderCredentials("123", "123") config := NewDefaultConfig()
config.AuthEmail = "123"
config.AuthKey = "123"
_, err := NewDNSProviderConfig(config)
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -54,7 +58,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("CLOUDFLARE_API_KEY", "") os.Setenv("CLOUDFLARE_API_KEY", "")
_, err := NewDNSProvider() _, 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) { func TestNewDNSProviderMissingCredErrSingle(t *testing.T) {
@ -62,7 +66,7 @@ func TestNewDNSProviderMissingCredErrSingle(t *testing.T) {
os.Setenv("CLOUDFLARE_EMAIL", "awesome@possum.com") os.Setenv("CLOUDFLARE_EMAIL", "awesome@possum.com")
_, err := NewDNSProvider() _, 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) { func TestCloudFlarePresent(t *testing.T) {
@ -70,7 +74,11 @@ func TestCloudFlarePresent(t *testing.T) {
t.Skip("skipping live test") 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) assert.NoError(t, err)
err = provider.Present(cflareDomain, "", "123d==") err = provider.Present(cflareDomain, "", "123d==")
@ -84,7 +92,11 @@ func TestCloudFlareCleanUp(t *testing.T) {
time.Sleep(time.Second * 2) 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) assert.NoError(t, err)
err = provider.CleanUp(cflareDomain, "", "123d==") err = provider.CleanUp(cflareDomain, "", "123d==")

View file

@ -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"`
}

View file

@ -5,7 +5,10 @@ package digitalocean
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"io/ioutil"
"net/http" "net/http"
"sync" "sync"
"time" "time"
@ -14,13 +17,35 @@ import (
"github.com/xenolf/lego/platform/config/env" "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 // DNSProvider is an implementation of the acme.ChallengeProvider interface
// that uses DigitalOcean's REST API to manage TXT records for a domain. // that uses DigitalOcean's REST API to manage TXT records for a domain.
type DNSProvider struct { type DNSProvider struct {
apiAuthToken string config *Config
recordIDs map[string]int recordIDs map[string]int
recordIDsMu sync.Mutex recordIDsMu sync.Mutex
client *http.Client
} }
// NewDNSProvider returns a DNSProvider instance configured for Digital // NewDNSProvider returns a DNSProvider instance configured for Digital
@ -29,74 +54,60 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("DO_AUTH_TOKEN") values, err := env.Get("DO_AUTH_TOKEN")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for Digital Ocean. // to return a DNSProvider instance configured for Digital Ocean.
// Deprecated
func NewDNSProviderCredentials(apiAuthToken string) (*DNSProvider, error) { func NewDNSProviderCredentials(apiAuthToken string) (*DNSProvider, error) {
if apiAuthToken == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("DigitalOcean credentials missing") 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{ return &DNSProvider{
apiAuthToken: apiAuthToken, config: config,
recordIDs: make(map[string]int), recordIDs: make(map[string]int),
client: &http.Client{Timeout: 30 * time.Second},
}, nil }, nil
} }
// Timeout returns the timeout and interval to use when checking for DNS // Timeout returns the timeout and interval to use when checking for DNS propagation.
// propagation. Adjusting here to cope with spikes in propagation times. // Adjusting here to cope with spikes in propagation times.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { 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 // Present creates a TXT record using the specified parameters
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, _ := acme.DNS01Record(domain, keyAuth) 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 { 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.recordIDsMu.Lock()
d.recordIDs[fqdn] = respData.DomainRecord.ID d.recordIDs[fqdn] = respData.DomainRecord.ID
d.recordIDsMu.Unlock() d.recordIDsMu.Unlock()
@ -113,35 +124,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
recordID, ok := d.recordIDs[fqdn] recordID, ok := d.recordIDs[fqdn]
d.recordIDsMu.Unlock() d.recordIDsMu.Unlock()
if !ok { 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 { 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/%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)
} }
// Delete record ID from map // Delete record ID from map
@ -152,27 +140,101 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil return nil
} }
type digitalOceanAPIError struct { func (d *DNSProvider) removeTxtRecord(domain string, recordID int) error {
ID string `json:"id"` authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers)
Message string `json:"message"` 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 reqData := txtRecordRequest{RecordType: "TXT", Name: fqdn, Data: value, TTL: d.config.TTL}
type txtRecordRequest struct { body, err := json.Marshal(reqData)
RecordType string `json:"type"` if err != nil {
Name string `json:"name"` return nil, err
Data string `json:"data"` }
TTL int `json:"ttl"`
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 func (d *DNSProvider) newRequest(method, reqURL string, body io.Reader) (*http.Request, error) {
type txtRecordResponse struct { req, err := http.NewRequest(method, reqURL, body)
DomainRecord struct { if err != nil {
ID int `json:"id"` return nil, err
Type string `json:"type"` }
Name string `json:"name"`
Data string `json:"data"` req.Header.Set("Content-Type", "application/json")
} `json:"domain_record"` 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))
} }

View file

@ -42,13 +42,16 @@ func TestDigitalOceanPresent(t *testing.T) {
}`) }`)
})) }))
defer mock.Close() 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.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") 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") 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) w.WriteHeader(http.StatusNoContent)
})) }))
defer mock.Close() 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.NoError(t, err)
require.NotNil(t, doprov) require.NotNil(t, provider)
doprov.recordIDsMu.Lock() provider.recordIDsMu.Lock()
doprov.recordIDs["_acme-challenge.example.com."] = 1234567 provider.recordIDs["_acme-challenge.example.com."] = 1234567
doprov.recordIDsMu.Unlock() provider.recordIDsMu.Unlock()
err = doprov.CleanUp("example.com", "", "") err = provider.CleanUp("example.com", "", "")
require.NoError(t, err, "fail to remove TXT record") 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") assert.True(t, requestReceived, "Expected request to be received by mock backend, but it wasn't")

View file

@ -3,17 +3,39 @@
package dnsimple package dnsimple
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/dnsimple/dnsimple-go/dnsimple" "github.com/dnsimple/dnsimple-go/dnsimple"
"github.com/xenolf/lego/acme" "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. // DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProvider struct { type DNSProvider struct {
config *Config
client *dnsimple.Client client *dnsimple.Client
} }
@ -22,24 +44,39 @@ type DNSProvider struct {
// //
// See: https://developer.dnsimple.com/v2/#authentication // See: https://developer.dnsimple.com/v2/#authentication
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
accessToken := os.Getenv("DNSIMPLE_OAUTH_TOKEN") config := NewDefaultConfig()
baseURL := os.Getenv("DNSIMPLE_BASE_URL") 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for dnsimple. // to return a DNSProvider instance configured for DNSimple.
// Deprecated
func NewDNSProviderCredentials(accessToken, baseURL string) (*DNSProvider, error) { func NewDNSProviderCredentials(accessToken, baseURL string) (*DNSProvider, error) {
if accessToken == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("DNSimple OAuth token is missing") 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)) if config.AccessToken == "" {
client.UserAgent = "lego" return nil, fmt.Errorf("dnsimple: OAuth token is missing")
}
if baseURL != "" { client := dnsimple.NewClient(dnsimple.NewOauthTokenCredentials(config.AccessToken))
client.BaseURL = baseURL client.UserAgent = acme.UserAgent
if config.BaseURL != "" {
client.BaseURL = config.BaseURL
} }
return &DNSProvider{client: client}, nil 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. // Present creates a TXT record to fulfil the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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) zoneName, err := d.getHostedZone(domain)
if err != nil { if err != nil {
return err return err
} }
@ -60,10 +96,10 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
return err return err
} }
recordAttributes := d.newTxtRecord(zoneName, fqdn, value, ttl) recordAttributes := d.newTxtRecord(zoneName, fqdn, value, d.config.TTL)
_, err = d.client.Zones.CreateRecord(accountID, zoneName, *recordAttributes) _, err = d.client.Zones.CreateRecord(accountID, zoneName, recordAttributes)
if err != nil { if err != nil {
return fmt.Errorf("DNSimple API call failed: %v", err) return fmt.Errorf("API call failed: %v", err)
} }
return nil return nil
@ -93,6 +129,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil 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) { func (d *DNSProvider) getHostedZone(domain string) (string, error) {
authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers)
if err != nil { 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}) zones, err := d.client.Zones.ListZones(accountID, &dnsimple.ZoneListOptions{NameLike: zoneName})
if err != nil { if err != nil {
return "", fmt.Errorf("DNSimple API call failed: %v", err) return "", fmt.Errorf("API call failed: %v", err)
} }
var hostedZone dnsimple.Zone 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{}}) result, err := d.client.Zones.ListRecords(accountID, zoneName, &dnsimple.ZoneRecordListOptions{Name: recordName, Type: "TXT", ListOptions: dnsimple.ListOptions{}})
if err != nil { 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 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) name := d.extractRecordName(fqdn, zoneName)
return &dnsimple.ZoneRecord{ return dnsimple.ZoneRecord{
Type: "TXT", Type: "TXT",
Name: name, Name: name,
Content: value, Content: value,
@ -172,7 +214,7 @@ func (d *DNSProvider) getAccountID() (string, error) {
} }
if whoamiResponse.Data.Account == nil { 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 return strconv.FormatInt(whoamiResponse.Data.Account.ID, 10), nil

View file

@ -6,6 +6,8 @@ import (
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/xenolf/lego/acme"
) )
var ( var (
@ -44,6 +46,8 @@ func TestNewDNSProviderValid(t *testing.T) {
defer restoreEnv() defer restoreEnv()
os.Setenv("DNSIMPLE_OAUTH_TOKEN", "123") os.Setenv("DNSIMPLE_OAUTH_TOKEN", "123")
acme.UserAgent = "lego"
provider, err := NewDNSProvider() provider, err := NewDNSProvider()
assert.NotNil(t, provider) assert.NotNil(t, provider)
@ -71,7 +75,7 @@ func TestNewDNSProviderInvalidWithMissingOauthToken(t *testing.T) {
provider, err := NewDNSProvider() provider, err := NewDNSProvider()
assert.Nil(t, provider) 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) { 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.Equal(t, "lego", provider.client.UserAgent)
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestNewDNSProviderCredentialsValidWithBaseUrl(t *testing.T) { 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) provider, err := NewDNSProviderConfig(config)
assert.NoError(t, err) require.NoError(t, err)
require.NotNil(t, provider)
assert.Equal(t, provider.client.BaseURL, "https://api.dnsimple.test") assert.Equal(t, provider.client.BaseURL, "https://api.dnsimple.test")
} }
func TestNewDNSProviderCredentialsInvalidWithMissingOauthToken(t *testing.T) { func TestNewDNSProviderCredentialsInvalidWithMissingOauthToken(t *testing.T) {
provider, err := NewDNSProviderCredentials("", "") config := NewDefaultConfig()
config.AccessToken = ""
config.BaseURL = ""
provider, err := NewDNSProviderConfig(config)
assert.Nil(t, provider) 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") 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) assert.NoError(t, err)
err = provider.Present(dnsimpleDomain, "", "123d==") err = provider.Present(dnsimpleDomain, "", "123d==")
@ -129,7 +149,11 @@ func TestLiveDNSimpleCleanUp(t *testing.T) {
time.Sleep(time.Second * 1) 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) assert.NoError(t, err)
err = provider.CleanUp(dnsimpleDomain, "", "123d==") err = provider.CleanUp(dnsimpleDomain, "", "123d==")

View file

@ -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
}

View file

@ -1,12 +1,8 @@
package dnsmadeeasy package dnsmadeeasy
import ( import (
"bytes"
"crypto/hmac"
"crypto/sha1"
"crypto/tls" "crypto/tls"
"encoding/hex" "errors"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@ -18,38 +14,46 @@ import (
"github.com/xenolf/lego/platform/config/env" "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 // DNSProvider is an implementation of the acme.ChallengeProvider interface that uses
// DNSMadeEasy's DNS API to manage TXT records for a domain. // DNSMadeEasy's DNS API to manage TXT records for a domain.
type DNSProvider struct { type DNSProvider struct {
baseURL string config *Config
apiKey string client *Client
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"`
} }
// NewDNSProvider returns a DNSProvider instance configured for DNSMadeEasy DNS. // NewDNSProvider returns a DNSProvider instance configured for DNSMadeEasy DNS.
// Credentials must be passed in the environment variables: DNSMADEEASY_API_KEY // Credentials must be passed in the environment variables:
// and DNSMADEEASY_API_SECRET. // DNSMADEEASY_API_KEY and DNSMADEEASY_API_SECRET.
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("DNSMADEEASY_API_KEY", "DNSMADEEASY_API_SECRET") values, err := env.Get("DNSMADEEASY_API_KEY", "DNSMADEEASY_API_SECRET")
if err != nil { if err != nil {
return nil, fmt.Errorf("DNSMadeEasy: %v", err) return nil, fmt.Errorf("dnsmadeeasy: %v", err)
} }
var baseURL string var baseURL string
@ -59,35 +63,53 @@ func NewDNSProvider() (*DNSProvider, error) {
baseURL = "https://api.dnsmadeeasy.com/V2.0" 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for DNSMadeEasy. // to return a DNSProvider instance configured for DNS Made Easy.
// Deprecated
func NewDNSProviderCredentials(baseURL, apiKey, apiSecret string) (*DNSProvider, error) { func NewDNSProviderCredentials(baseURL, apiKey, apiSecret string) (*DNSProvider, error) {
if baseURL == "" || apiKey == "" || apiSecret == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("DNS Made Easy credentials missing") 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{ if config.BaseURL == "" {
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, return nil, fmt.Errorf("dnsmadeeasy: base URL missing")
} }
client := &http.Client{
Transport: transport, client, err := NewClient(config.APIKey, config.APISecret)
Timeout: 10 * time.Second, if err != nil {
return nil, fmt.Errorf("dnsmadeeasy: %v", err)
} }
client.HTTPClient = config.HTTPClient
client.BaseURL = config.BaseURL
return &DNSProvider{ return &DNSProvider{
baseURL: baseURL,
apiKey: apiKey,
apiSecret: apiSecret,
client: client, client: client,
config: config,
}, nil }, nil
} }
// Present creates a TXT record using the specified parameters // Present creates a TXT record using the specified parameters
func (d *DNSProvider) Present(domainName, token, keyAuth string) error { 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) authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers)
if err != nil { if err != nil {
@ -95,16 +117,16 @@ func (d *DNSProvider) Present(domainName, token, keyAuth string) error {
} }
// fetch the domain details // fetch the domain details
domain, err := d.getDomain(authZone) domain, err := d.client.GetDomain(authZone)
if err != nil { if err != nil {
return err return err
} }
// create the TXT record // create the TXT record
name := strings.Replace(fqdn, "."+authZone, "", 1) 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 return err
} }
@ -118,21 +140,21 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error {
} }
// fetch the domain details // fetch the domain details
domain, err := d.getDomain(authZone) domain, err := d.client.GetDomain(authZone)
if err != nil { if err != nil {
return err return err
} }
// find matching records // find matching records
name := strings.Replace(fqdn, "."+authZone, "", 1) name := strings.Replace(fqdn, "."+authZone, "", 1)
records, err := d.getRecords(domain, name, "TXT") records, err := d.client.GetRecords(domain, name, "TXT")
if err != nil { if err != nil {
return err return err
} }
// delete records // delete records
for _, record := range *records { for _, record := range *records {
err = d.deleteRecord(record) err = d.client.DeleteRecord(record)
if err != nil { if err != nil {
return err return err
} }
@ -141,107 +163,8 @@ func (d *DNSProvider) CleanUp(domainName, token, keyAuth string) error {
return nil return nil
} }
func (d *DNSProvider) getDomain(authZone string) (*Domain, error) { // Timeout returns the timeout and interval to use when checking for DNS propagation.
domainName := authZone[0 : len(authZone)-1] // Adjusting here to cope with spikes in propagation times.
resource := fmt.Sprintf("%s%s", "/dns/managed/name?domainname=", domainName) func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
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))
} }

View file

@ -3,16 +3,42 @@
package dnspod package dnspod
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"strconv"
"strings" "strings"
"time"
"github.com/decker502/dnspod-go" "github.com/decker502/dnspod-go"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
"github.com/xenolf/lego/platform/config/env" "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. // DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProvider struct { type DNSProvider struct {
config *Config
client *dnspod.Client client *dnspod.Client
} }
@ -21,37 +47,55 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("DNSPOD_API_KEY") values, err := env.Get("DNSPOD_API_KEY")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for dnspod. // to return a DNSProvider instance configured for dnspod.
// Deprecated
func NewDNSProviderCredentials(key string) (*DNSProvider, error) { func NewDNSProviderCredentials(key string) (*DNSProvider, error) {
if key == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("dnspod credentials missing") 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"} if config.LoginToken == "" {
return &DNSProvider{ return nil, fmt.Errorf("dnspod: credentials missing")
client: dnspod.NewClient(params), }
}, nil
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. // Present creates a TXT record to fulfil the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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) zoneID, zoneName, err := d.getHostedZone(domain)
if err != nil { if err != nil {
return err 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) _, _, err = d.client.Domains.CreateRecord(zoneID, *recordAttributes)
if err != nil { if err != nil {
return fmt.Errorf("dnspod API call failed: %v", err) return fmt.Errorf("API call failed: %v", err)
} }
return nil return nil
@ -80,10 +124,16 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil 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) { func (d *DNSProvider) getHostedZone(domain string) (string, string, error) {
zones, _, err := d.client.Domains.List() zones, _, err := d.client.Domains.List()
if err != nil { 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) 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, Name: name,
Value: value, Value: value,
Line: "默认", 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 var records []dnspod.Record
result, _, err := d.client.Domains.ListRecords(zoneID, "") result, _, err := d.client.Domains.ListRecords(zoneID, "")
if err != nil { 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) recordName := d.extractRecordName(fqdn, zoneName)

View file

@ -30,7 +30,10 @@ func TestNewDNSProviderValid(t *testing.T) {
defer restoreEnv() defer restoreEnv()
os.Setenv("DNSPOD_API_KEY", "") os.Setenv("DNSPOD_API_KEY", "")
_, err := NewDNSProviderCredentials("123") config := NewDefaultConfig()
config.LoginToken = "123"
_, err := NewDNSProviderConfig(config)
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestNewDNSProviderValidEnv(t *testing.T) { func TestNewDNSProviderValidEnv(t *testing.T) {
@ -46,7 +49,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("DNSPOD_API_KEY", "") os.Setenv("DNSPOD_API_KEY", "")
_, err := NewDNSProvider() _, 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) { func TestLivednspodPresent(t *testing.T) {
@ -54,7 +57,10 @@ func TestLivednspodPresent(t *testing.T) {
t.Skip("skipping live test") t.Skip("skipping live test")
} }
provider, err := NewDNSProviderCredentials(dnspodAPIKey) config := NewDefaultConfig()
config.LoginToken = dnspodAPIKey
provider, err := NewDNSProviderConfig(config)
assert.NoError(t, err) assert.NoError(t, err)
err = provider.Present(dnspodDomain, "", "123d==") err = provider.Present(dnspodDomain, "", "123d==")
@ -68,7 +74,10 @@ func TestLivednspodCleanUp(t *testing.T) {
time.Sleep(time.Second * 1) time.Sleep(time.Second * 1)
provider, err := NewDNSProviderCredentials(dnspodAPIKey) config := NewDefaultConfig()
config.LoginToken = dnspodAPIKey
provider, err := NewDNSProviderConfig(config)
assert.NoError(t, err) assert.NoError(t, err)
err = provider.CleanUp(dnspodDomain, "", "123d==") err = provider.CleanUp(dnspodDomain, "", "123d==")

View file

@ -6,15 +6,36 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http"
"time"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
"github.com/xenolf/lego/platform/config/env" "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 // DNSProvider adds and removes the record for the DNS challenge
type DNSProvider struct { type DNSProvider struct {
// The api token config *Config
token string
} }
// NewDNSProvider returns a new DNS provider using // NewDNSProvider returns a new DNS provider using
@ -22,31 +43,53 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("DUCKDNS_TOKEN") values, err := env.Get("DUCKDNS_TOKEN")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for http://duckdns.org . // to return a DNSProvider instance configured for http://duckdns.org
// Deprecated
func NewDNSProviderCredentials(token string) (*DNSProvider, error) { func NewDNSProviderCredentials(token string) (*DNSProvider, error) {
if token == "" { config := NewDefaultConfig()
return nil, errors.New("DuckDNS: credentials missing") 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. // Present creates a TXT record to fulfil the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
_, txtRecord, _ := acme.DNS01Record(domain, keyAuth) _, 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 // CleanUp clears DuckDNS TXT record
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { 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 // updateTxtRecord Update the domains TXT record

View file

@ -39,7 +39,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("DUCKDNS_TOKEN", "") os.Setenv("DUCKDNS_TOKEN", "")
_, err := NewDNSProvider() _, 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) { func TestLiveDuckdnsPresent(t *testing.T) {

View file

@ -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"`
}

View file

@ -5,6 +5,7 @@ package dyn
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -14,122 +15,166 @@ import (
"github.com/xenolf/lego/platform/config/env" "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 { // NewDefaultConfig returns a default configuration for the DNSProvider
// One of 'success', 'failure', or 'incomplete' func NewDefaultConfig() *Config {
Status string `json:"status"` return &Config{
TTL: env.GetOrDefaultInt("DYN_TTL", 120),
// The structure containing the actual results of the request PropagationTimeout: env.GetOrDefaultSecond("DYN_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout),
Data json.RawMessage `json:"data"` PollingInterval: env.GetOrDefaultSecond("DYN_POLLING_INTERVAL", acme.DefaultPollingInterval),
HTTPClient: &http.Client{
// The ID of the job that was created in response to a request. Timeout: env.GetOrDefaultSecond("DYN_HTTP_TIMEOUT", 10*time.Second),
JobID int `json:"job_id"` },
}
// A list of zero or more messages
Messages json.RawMessage `json:"msgs"`
} }
// DNSProvider is an implementation of the acme.ChallengeProvider interface that uses // DNSProvider is an implementation of the acme.ChallengeProvider interface that uses
// Dyn's Managed DNS API to manage TXT records for a domain. // Dyn's Managed DNS API to manage TXT records for a domain.
type DNSProvider struct { type DNSProvider struct {
customerName string config *Config
userName string
password string
token string token string
client *http.Client
} }
// NewDNSProvider returns a DNSProvider instance configured for Dyn DNS. // NewDNSProvider returns a DNSProvider instance configured for Dyn DNS.
// Credentials must be passed in the environment variables: DYN_CUSTOMER_NAME, // Credentials must be passed in the environment variables:
// DYN_USER_NAME and DYN_PASSWORD. // DYN_CUSTOMER_NAME, DYN_USER_NAME and DYN_PASSWORD.
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("DYN_CUSTOMER_NAME", "DYN_USER_NAME", "DYN_PASSWORD") values, err := env.Get("DYN_CUSTOMER_NAME", "DYN_USER_NAME", "DYN_PASSWORD")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for Dyn DNS. // to return a DNSProvider instance configured for Dyn DNS.
// Deprecated
func NewDNSProviderCredentials(customerName, userName, password string) (*DNSProvider, error) { func NewDNSProviderCredentials(customerName, userName, password string) (*DNSProvider, error) {
if customerName == "" || userName == "" || password == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("DynDNS credentials missing") config.CustomerName = customerName
} config.UserName = userName
config.Password = password
return &DNSProvider{ return NewDNSProviderConfig(config)
customerName: customerName,
userName: userName,
password: password,
client: &http.Client{Timeout: 10 * time.Second},
}, nil
} }
func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) (*dynResponse, error) { // NewDNSProviderConfig return a DNSProvider instance configured for Dyn DNS
url := fmt.Sprintf("%s/%s", dynBaseURL, resource) func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
body, err := json.Marshal(payload) return nil, errors.New("dyn: the configuration of the DNS provider is nil")
if err != nil {
return nil, err
} }
req, err := http.NewRequest(method, url, bytes.NewReader(body)) if config.CustomerName == "" || config.UserName == "" || config.Password == "" {
if err != nil { return nil, fmt.Errorf("dyn: credentials missing")
return nil, err
} }
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") 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 { if err != nil {
return nil, err return fmt.Errorf("dyn: %v", err)
} }
defer resp.Body.Close() resp.Body.Close()
if resp.StatusCode >= 500 { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Dyn API request failed with HTTP status code %d", resp.StatusCode) return fmt.Errorf("dyn: API request failed to delete TXT record HTTP status code %d", resp.StatusCode)
} }
var dynRes dynResponse err = d.publish(authZone, "Removed TXT record for ACME dns-01 challenge using lego client")
err = json.NewDecoder(resp.Body).Decode(&dynRes)
if err != nil { if err != nil {
return nil, err return fmt.Errorf("dyn: %v", err)
} }
if resp.StatusCode >= 400 { return d.logout()
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")
}
if dynRes.Status == "failure" { // Timeout returns the timeout and interval to use when checking for DNS propagation.
// TODO add better error handling // Adjusting here to cope with spikes in propagation times.
return nil, fmt.Errorf("Dyn API request failed: %s", dynRes.Messages) func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
} return d.config.PropagationTimeout, d.config.PollingInterval
return &dynRes, nil
} }
// Starts a new Dyn API Session. Authenticates using customerName, userName, // Starts a new Dyn API Session. Authenticates using customerName, userName,
// password and receives a token to be used in for subsequent requests. // password and receives a token to be used in for subsequent requests.
func (d *DNSProvider) login() error { func (d *DNSProvider) login() error {
type creds struct { payload := &creds{Customer: d.config.CustomerName, User: d.config.UserName, Pass: d.config.Password}
Customer string `json:"customer_name"`
User string `json:"user_name"`
Pass string `json:"password"`
}
type session struct {
Token string `json:"token"`
Version string `json:"version"`
}
payload := &creds{Customer: d.customerName, User: d.userName, Pass: d.password}
dynRes, err := d.sendRequest(http.MethodPost, "Session", payload) dynRes, err := d.sendRequest(http.MethodPost, "Session", payload)
if err != nil { if err != nil {
return err return err
@ -153,7 +198,7 @@ func (d *DNSProvider) logout() error {
return nil return nil
} }
url := fmt.Sprintf("%s/Session", dynBaseURL) url := fmt.Sprintf("%s/Session", defaultBaseURL)
req, err := http.NewRequest(http.MethodDelete, url, nil) req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil { if err != nil {
return err return err
@ -161,14 +206,14 @@ func (d *DNSProvider) logout() error {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
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 { if err != nil {
return err return err
} }
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Dyn API request failed to delete session with HTTP status code %d", resp.StatusCode) return fmt.Errorf("API request failed to delete session with HTTP status code %d", resp.StatusCode)
} }
d.token = "" d.token = ""
@ -176,47 +221,7 @@ func (d *DNSProvider) logout() error {
return nil 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 { 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} pub := &publish{Publish: true, Notes: notes}
resource := fmt.Sprintf("Zone/%s/", zone) resource := fmt.Sprintf("Zone/%s/", zone)
@ -224,45 +229,50 @@ func (d *DNSProvider) publish(zone, notes string) error {
return err return err
} }
// CleanUp removes the TXT record matching the specified parameters func (d *DNSProvider) sendRequest(method, resource string, payload interface{}) (*dynResponse, error) {
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { url := fmt.Sprintf("%s/%s", defaultBaseURL, resource)
fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) body, err := json.Marshal(payload)
if err != nil { if err != nil {
return err return nil, err
} }
err = d.login() req, err := http.NewRequest(method, url, bytes.NewReader(body))
if err != nil { 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("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 { if err != nil {
return err return nil, err
} }
resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode >= 500 {
return fmt.Errorf("Dyn API request failed to delete TXT record HTTP status code %d", resp.StatusCode) 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 { 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
} }

View file

@ -5,15 +5,43 @@ package exoscale
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/http"
"os" "os"
"time"
"github.com/exoscale/egoscale" "github.com/exoscale/egoscale"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
"github.com/xenolf/lego/platform/config/env" "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. // DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProvider struct { type DNSProvider struct {
config *Config
client *egoscale.Client client *egoscale.Client
} }
@ -22,32 +50,52 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("EXOSCALE_API_KEY", "EXOSCALE_API_SECRET") values, err := env.Get("EXOSCALE_API_KEY", "EXOSCALE_API_SECRET")
if err != nil { if err != nil {
return nil, fmt.Errorf("Exoscale: %v", err) return nil, fmt.Errorf("exoscale: %v", err)
} }
endpoint := os.Getenv("EXOSCALE_ENDPOINT") config := NewDefaultConfig()
return NewDNSProviderClient(values["EXOSCALE_API_KEY"], values["EXOSCALE_API_SECRET"], endpoint) 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 // NewDNSProviderClient Uses the supplied parameters
// configured for Exoscale. // to return a DNSProvider instance configured for Exoscale.
// Deprecated
func NewDNSProviderClient(key, secret, endpoint string) (*DNSProvider, error) { func NewDNSProviderClient(key, secret, endpoint string) (*DNSProvider, error) {
if key == "" || secret == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("Exoscale credentials missing") 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 == "" { if config.APIKey == "" || config.APISecret == "" {
endpoint = "https://api.exoscale.ch/dns" return nil, fmt.Errorf("exoscale: credentials missing")
} }
return &DNSProvider{ if config.Endpoint == "" {
client: egoscale.NewClient(endpoint, key, secret), config.Endpoint = defaultBaseURL
}, nil }
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. // Present creates a TXT record to fulfil the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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) zone, recordName, err := d.FindZoneAndRecordName(fqdn, domain)
if err != nil { if err != nil {
return err return err
@ -61,7 +109,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
if recordID == 0 { if recordID == 0 {
record := egoscale.DNSRecord{ record := egoscale.DNSRecord{
Name: recordName, Name: recordName,
TTL: ttl, TTL: d.config.TTL,
Content: value, Content: value,
RecordType: "TXT", RecordType: "TXT",
} }
@ -74,7 +122,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
record := egoscale.UpdateDNSRecord{ record := egoscale.UpdateDNSRecord{
ID: recordID, ID: recordID,
Name: recordName, Name: recordName,
TTL: ttl, TTL: d.config.TTL,
Content: value, Content: value,
RecordType: "TXT", RecordType: "TXT",
} }
@ -111,6 +159,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil 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. // FindExistingRecordID Query Exoscale to find an existing record for this name.
// Returns nil if no record could be found // Returns nil if no record could be found
func (d *DNSProvider) FindExistingRecordID(zone, recordName string) (int64, error) { func (d *DNSProvider) FindExistingRecordID(zone, recordName string) (int64, error) {

View file

@ -34,7 +34,11 @@ func TestNewDNSProviderValid(t *testing.T) {
os.Setenv("EXOSCALE_API_KEY", "") os.Setenv("EXOSCALE_API_KEY", "")
os.Setenv("EXOSCALE_API_SECRET", "") 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) assert.NoError(t, err)
} }
@ -53,11 +57,15 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
defer restoreEnv() defer restoreEnv()
_, err := NewDNSProvider() _, 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) { 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) assert.NoError(t, err)
zone, recordName, err := provider.FindZoneAndRecordName("_acme-challenge.bar.com.", "bar.com") 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) { 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) assert.NoError(t, err)
zone, recordName, err := provider.FindZoneAndRecordName("_acme-challenge.foo.bar.com.", "foo.bar.com") 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") 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) assert.NoError(t, err)
err = provider.Present(exoscaleDomain, "", "123d==") err = provider.Present(exoscaleDomain, "", "123d==")
@ -99,7 +115,11 @@ func TestLiveExoscaleCleanUp(t *testing.T) {
time.Sleep(time.Second * 1) 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) assert.NoError(t, err)
err = provider.CleanUp(exoscaleDomain, "", "123d==") err = provider.CleanUp(exoscaleDomain, "", "123d==")

View file

@ -1,8 +1,10 @@
package fastdns package fastdns
import ( import (
"errors"
"fmt" "fmt"
"reflect" "reflect"
"time"
configdns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v1" configdns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v1"
"github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid" "github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid"
@ -10,9 +12,26 @@ import (
"github.com/xenolf/lego/platform/config/env" "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. // DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProvider struct { type DNSProvider struct {
config edgegrid.Config config *Config
} }
// NewDNSProvider uses the supplied environment variables to return a DNSProvider instance: // NewDNSProvider uses the supplied environment variables to return a DNSProvider instance:
@ -20,24 +39,27 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("AKAMAI_HOST", "AKAMAI_CLIENT_TOKEN", "AKAMAI_CLIENT_SECRET", "AKAMAI_ACCESS_TOKEN") values, err := env.Get("AKAMAI_HOST", "AKAMAI_CLIENT_TOKEN", "AKAMAI_CLIENT_SECRET", "AKAMAI_ACCESS_TOKEN")
if err != nil { if err != nil {
return nil, fmt.Errorf("FastDNS: %v", err) return nil, fmt.Errorf("fastdns: %v", err)
} }
return NewDNSProviderClient( config := NewDefaultConfig()
values["AKAMAI_HOST"], config.Config = edgegrid.Config{
values["AKAMAI_CLIENT_TOKEN"], Host: values["AKAMAI_HOST"],
values["AKAMAI_CLIENT_SECRET"], ClientToken: values["AKAMAI_CLIENT_TOKEN"],
values["AKAMAI_ACCESS_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 // NewDNSProviderClient uses the supplied parameters
// configured for FastDNS. // to return a DNSProvider instance configured for FastDNS.
// Deprecated
func NewDNSProviderClient(host, clientToken, clientSecret, accessToken string) (*DNSProvider, error) { func NewDNSProviderClient(host, clientToken, clientSecret, accessToken string) (*DNSProvider, error) {
if clientToken == "" || clientSecret == "" || accessToken == "" || host == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("FastDNS credentials are missing") config.Config = edgegrid.Config{
}
config := edgegrid.Config{
Host: host, Host: host,
ClientToken: clientToken, ClientToken: clientToken,
ClientSecret: clientSecret, ClientSecret: clientSecret,
@ -45,29 +67,40 @@ func NewDNSProviderClient(host, clientToken, clientSecret, accessToken string) (
MaxBody: 131072, MaxBody: 131072,
} }
return &DNSProvider{ return NewDNSProviderConfig(config)
config: config, }
}, nil
// 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. // Present creates a TXT record to fullfil the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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) zoneName, recordName, err := d.findZoneAndRecordName(fqdn, domain)
if err != nil { 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) zone, err := configdns.GetZone(zoneName)
if err != nil { if err != nil {
return err return fmt.Errorf("fastdns: %v", err)
} }
record := configdns.NewTxtRecord() record := configdns.NewTxtRecord()
record.SetField("name", recordName) record.SetField("name", recordName)
record.SetField("ttl", ttl) record.SetField("ttl", d.config.TTL)
record.SetField("target", value) record.SetField("target", value)
record.SetField("active", true) record.SetField("active", true)
@ -89,14 +122,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := acme.DNS01Record(domain, keyAuth) fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
zoneName, recordName, err := d.findZoneAndRecordName(fqdn, domain) zoneName, recordName, err := d.findZoneAndRecordName(fqdn, domain)
if err != nil { 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) zone, err := configdns.GetZone(zoneName)
if err != nil { if err != nil {
return err return fmt.Errorf("fastdns: %v", err)
} }
existingRecord := d.findExistingRecord(zone, recordName) existingRecord := d.findExistingRecord(zone, recordName)
@ -104,7 +137,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
if existingRecord != nil { if existingRecord != nil {
err := zone.RemoveRecord(existingRecord) err := zone.RemoveRecord(existingRecord)
if err != nil { if err != nil {
return err return fmt.Errorf("fastdns: %v", err)
} }
return zone.Save() return zone.Save()
} }
@ -112,6 +145,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil 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) { func (d *DNSProvider) findZoneAndRecordName(fqdn, domain string) (string, string, error) {
zone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) zone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers)
if err != nil { if err != nil {

View file

@ -43,7 +43,13 @@ func TestNewDNSProviderValid(t *testing.T) {
os.Setenv("AKAMAI_CLIENT_SECRET", "") os.Setenv("AKAMAI_CLIENT_SECRET", "")
os.Setenv("AKAMAI_ACCESS_TOKEN", "") 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) assert.NoError(t, err)
} }
@ -66,7 +72,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("AKAMAI_ACCESS_TOKEN", "") os.Setenv("AKAMAI_ACCESS_TOKEN", "")
_, err := NewDNSProvider() _, 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) { func TestLiveFastdnsPresent(t *testing.T) {
@ -74,7 +80,13 @@ func TestLiveFastdnsPresent(t *testing.T) {
t.Skip("skipping live test") 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) assert.NoError(t, err)
err = provider.Present(testDomain, "", "123d==") err = provider.Present(testDomain, "", "123d==")
@ -86,7 +98,13 @@ func TestLiveFastdnsPresent(t *testing.T) {
} }
func TestExtractRootRecordName(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) assert.NoError(t, err)
zone, recordName, err := provider.findZoneAndRecordName("_acme-challenge.bar.com.", "bar.com") 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) { 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) assert.NoError(t, err)
zone, recordName, err := provider.findZoneAndRecordName("_acme-challenge.foo.bar.com.", "foo.bar.com") 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) 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) assert.NoError(t, err)
err = provider.CleanUp(testDomain, "", "123d==") err = provider.CleanUp(testDomain, "", "123d==")

View file

@ -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)
}

View file

@ -5,6 +5,7 @@ package gandi
import ( import (
"bytes" "bytes"
"encoding/xml" "encoding/xml"
"errors"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
@ -20,15 +21,38 @@ import (
// Gandi API reference: http://doc.rpc.gandi.net/index.html // Gandi API reference: http://doc.rpc.gandi.net/index.html
// Gandi API domain examples: http://doc.rpc.gandi.net/domain/faq.html // Gandi API domain examples: http://doc.rpc.gandi.net/domain/faq.html
var ( const (
// endpoint is the Gandi XML-RPC endpoint used by Present and // defaultBaseURL Gandi XML-RPC endpoint used by Present and CleanUp
// CleanUp. It is overridden during tests. defaultBaseURL = "https://rpc.gandi.net/xmlrpc/"
endpoint = "https://rpc.gandi.net/xmlrpc/" minTTL = 300
// findZoneByFqdn determines the DNS zone of an fqdn. It is overridden
// during tests.
findZoneByFqdn = acme.FindZoneByFqdn
) )
// 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 // inProgressInfo contains information about an in-progress challenge
type inProgressInfo struct { type inProgressInfo struct {
zoneID int // zoneID of gandi zone to restore in CleanUp 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 // acme.ChallengeProviderTimeout interface that uses Gandi's XML-RPC
// API to manage TXT records for a domain. // API to manage TXT records for a domain.
type DNSProvider struct { type DNSProvider struct {
apiKey string
inProgressFQDNs map[string]inProgressInfo inProgressFQDNs map[string]inProgressInfo
inProgressAuthZones map[string]struct{} inProgressAuthZones map[string]struct{}
inProgressMu sync.Mutex inProgressMu sync.Mutex
client *http.Client config *Config
} }
// NewDNSProvider returns a DNSProvider instance configured for Gandi. // NewDNSProvider returns a DNSProvider instance configured for Gandi.
@ -52,23 +75,43 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("GANDI_API_KEY") values, err := env.Get("GANDI_API_KEY")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for Gandi. // to return a DNSProvider instance configured for Gandi.
// Deprecated
func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) {
if apiKey == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("no Gandi API Key given") 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{ return &DNSProvider{
apiKey: apiKey, config: config,
inProgressFQDNs: make(map[string]inProgressInfo), inProgressFQDNs: make(map[string]inProgressInfo),
inProgressAuthZones: make(map[string]struct{}), inProgressAuthZones: make(map[string]struct{}),
client: &http.Client{Timeout: 60 * time.Second},
}, nil }, nil
} }
@ -76,27 +119,27 @@ func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) {
// does this by creating and activating a new temporary Gandi DNS // does this by creating and activating a new temporary Gandi DNS
// zone. This new zone contains the TXT record. // zone. This new zone contains the TXT record.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
if ttl < 300 {
ttl = 300 // 300 is gandi minimum value for ttl if d.config.TTL < minTTL {
d.config.TTL = minTTL // 300 is gandi minimum value for ttl
} }
// find authZone and Gandi zone_id for fqdn // find authZone and Gandi zone_id for fqdn
authZone, err := findZoneByFqdn(fqdn, acme.RecursiveNameservers) authZone, err := findZoneByFqdn(fqdn, acme.RecursiveNameservers)
if err != nil { 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) zoneID, err := d.getZoneID(authZone)
if err != nil { if err != nil {
return err return fmt.Errorf("gandi: %v", err)
} }
// determine name of TXT record // determine name of TXT record
if !strings.HasSuffix( if !strings.HasSuffix(
strings.ToLower(fqdn), strings.ToLower("."+authZone)) { strings.ToLower(fqdn), strings.ToLower("."+authZone)) {
return fmt.Errorf( return fmt.Errorf("gandi: unexpected authZone %s for fqdn %s", authZone, fqdn)
"Gandi DNS: unexpected authZone %s for fqdn %s", authZone, fqdn)
} }
name := fqdn[:len(fqdn)-len("."+authZone)] name := fqdn[:len(fqdn)-len("."+authZone)]
@ -106,16 +149,12 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
defer d.inProgressMu.Unlock() defer d.inProgressMu.Unlock()
if _, ok := d.inProgressAuthZones[authZone]; ok { if _, ok := d.inProgressAuthZones[authZone]; ok {
return fmt.Errorf( return fmt.Errorf("gandi: challenge already in progress for authZone %s", authZone)
"Gandi DNS: challenge already in progress for authZone %s",
authZone)
} }
// perform API actions to create and activate new gandi zone // perform API actions to create and activate new gandi zone
// containing the required TXT record // containing the required TXT record
newZoneName := fmt.Sprintf( newZoneName := fmt.Sprintf("%s [ACME Challenge %s]", acme.UnFqdn(authZone), time.Now().Format(time.RFC822Z))
"%s [ACME Challenge %s]",
acme.UnFqdn(authZone), time.Now().Format(time.RFC822Z))
newZoneID, err := d.cloneZone(zoneID, newZoneName) newZoneID, err := d.cloneZone(zoneID, newZoneName)
if err != nil { if err != nil {
@ -124,22 +163,22 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
newZoneVersion, err := d.newZoneVersion(newZoneID) newZoneVersion, err := d.newZoneVersion(newZoneID)
if err != nil { 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 { if err != nil {
return err return fmt.Errorf("gandi: %v", err)
} }
err = d.setZoneVersion(newZoneID, newZoneVersion) err = d.setZoneVersion(newZoneID, newZoneVersion)
if err != nil { if err != nil {
return err return fmt.Errorf("gandi: %v", err)
} }
err = d.setZone(authZone, newZoneID) err = d.setZone(authZone, newZoneID)
if err != nil { if err != nil {
return err return fmt.Errorf("gandi: %v", err)
} }
// save data necessary for CleanUp // save data necessary for CleanUp
@ -149,6 +188,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
authZone: authZone, authZone: authZone,
} }
d.inProgressAuthZones[authZone] = struct{}{} d.inProgressAuthZones[authZone] = struct{}{}
return nil return nil
} }
@ -157,6 +197,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// removing the temporary one created by Present. // removing the temporary one created by Present.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := acme.DNS01Record(domain, keyAuth) fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
// acquire lock and retrieve zoneID, newZoneID and authZone // acquire lock and retrieve zoneID, newZoneID and authZone
d.inProgressMu.Lock() d.inProgressMu.Lock()
defer d.inProgressMu.Unlock() 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 // perform API actions to restore old gandi zone for authZone
err := d.setZone(authZone, zoneID) err := d.setZone(authZone, zoneID)
if err != nil { if err != nil {
return err return fmt.Errorf("gandi: %v", err)
} }
return d.deleteZone(newZoneID) 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 // are used by the acme package as timeout and check interval values
// when checking for DNS record propagation with Gandi. // when checking for DNS record propagation with Gandi.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return 40 * time.Minute, 60 * time.Second return d.config.PropagationTimeout, d.config.PollingInterval
}
// 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
} }
// rpcCall makes an XML-RPC call to Gandi's RPC endpoint by // 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 // marshal
b, err := xml.MarshalIndent(call, "", " ") b, err := xml.MarshalIndent(call, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("Gandi DNS: Marshal Error: %v", err) return fmt.Errorf("marshal error: %v", err)
} }
// post // post
b = append([]byte(`<?xml version="1.0"?>`+"\n"), b...) b = append([]byte(`<?xml version="1.0"?>`+"\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 { if err != nil {
return err return err
} }
@ -311,7 +250,7 @@ func (d *DNSProvider) rpcCall(call *methodCall, resp response) error {
// unmarshal // unmarshal
err = xml.Unmarshal(respBody, resp) err = xml.Unmarshal(respBody, resp)
if err != nil { if err != nil {
return fmt.Errorf("Gandi DNS: Unmarshal Error: %v", err) return fmt.Errorf("unmarshal error: %v", err)
} }
if resp.faultCode() != 0 { if resp.faultCode() != 0 {
return rpcError{ return rpcError{
@ -327,7 +266,7 @@ func (d *DNSProvider) getZoneID(domain string) (int, error) {
err := d.rpcCall(&methodCall{ err := d.rpcCall(&methodCall{
MethodName: "domain.info", MethodName: "domain.info",
Params: []param{ Params: []param{
paramString{Value: d.apiKey}, paramString{Value: d.config.APIKey},
paramString{Value: domain}, paramString{Value: domain},
}, },
}, resp) }, resp)
@ -343,8 +282,7 @@ func (d *DNSProvider) getZoneID(domain string) (int, error) {
} }
if zoneID == 0 { if zoneID == 0 {
return 0, fmt.Errorf( return 0, fmt.Errorf("could not determine zone_id for %s", domain)
"Gandi DNS: Could not determine zone_id for %s", domain)
} }
return zoneID, nil return zoneID, nil
} }
@ -354,7 +292,7 @@ func (d *DNSProvider) cloneZone(zoneID int, name string) (int, error) {
err := d.rpcCall(&methodCall{ err := d.rpcCall(&methodCall{
MethodName: "domain.zone.clone", MethodName: "domain.zone.clone",
Params: []param{ Params: []param{
paramString{Value: d.apiKey}, paramString{Value: d.config.APIKey},
paramInt{Value: zoneID}, paramInt{Value: zoneID},
paramInt{Value: 0}, paramInt{Value: 0},
paramStruct{ paramStruct{
@ -378,7 +316,7 @@ func (d *DNSProvider) cloneZone(zoneID int, name string) (int, error) {
} }
if newZoneID == 0 { 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 return newZoneID, nil
} }
@ -388,7 +326,7 @@ func (d *DNSProvider) newZoneVersion(zoneID int) (int, error) {
err := d.rpcCall(&methodCall{ err := d.rpcCall(&methodCall{
MethodName: "domain.zone.version.new", MethodName: "domain.zone.version.new",
Params: []param{ Params: []param{
paramString{Value: d.apiKey}, paramString{Value: d.config.APIKey},
paramInt{Value: zoneID}, paramInt{Value: zoneID},
}, },
}, resp) }, resp)
@ -397,7 +335,7 @@ func (d *DNSProvider) newZoneVersion(zoneID int) (int, error) {
} }
if resp.Value == 0 { 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 return resp.Value, nil
} }
@ -407,7 +345,7 @@ func (d *DNSProvider) addTXTRecord(zoneID int, version int, name string, value s
err := d.rpcCall(&methodCall{ err := d.rpcCall(&methodCall{
MethodName: "domain.zone.record.add", MethodName: "domain.zone.record.add",
Params: []param{ Params: []param{
paramString{Value: d.apiKey}, paramString{Value: d.config.APIKey},
paramInt{Value: zoneID}, paramInt{Value: zoneID},
paramInt{Value: version}, paramInt{Value: version},
paramStruct{ paramStruct{
@ -436,7 +374,7 @@ func (d *DNSProvider) setZoneVersion(zoneID int, version int) error {
err := d.rpcCall(&methodCall{ err := d.rpcCall(&methodCall{
MethodName: "domain.zone.version.set", MethodName: "domain.zone.version.set",
Params: []param{ Params: []param{
paramString{Value: d.apiKey}, paramString{Value: d.config.APIKey},
paramInt{Value: zoneID}, paramInt{Value: zoneID},
paramInt{Value: version}, paramInt{Value: version},
}, },
@ -446,7 +384,7 @@ func (d *DNSProvider) setZoneVersion(zoneID int, version int) error {
} }
if !resp.Value { if !resp.Value {
return fmt.Errorf("Gandi DNS: could not set zone version") return fmt.Errorf("could not set zone version")
} }
return nil return nil
} }
@ -456,7 +394,7 @@ func (d *DNSProvider) setZone(domain string, zoneID int) error {
err := d.rpcCall(&methodCall{ err := d.rpcCall(&methodCall{
MethodName: "domain.zone.set", MethodName: "domain.zone.set",
Params: []param{ Params: []param{
paramString{Value: d.apiKey}, paramString{Value: d.config.APIKey},
paramString{Value: domain}, paramString{Value: domain},
paramInt{Value: zoneID}, paramInt{Value: zoneID},
}, },
@ -473,8 +411,7 @@ func (d *DNSProvider) setZone(domain string, zoneID int) error {
} }
if respZoneID != zoneID { if respZoneID != zoneID {
return fmt.Errorf( return fmt.Errorf("could not set new zone_id for %s", domain)
"Gandi DNS: Could not set new zone_id for %s", domain)
} }
return nil return nil
} }
@ -484,7 +421,7 @@ func (d *DNSProvider) deleteZone(zoneID int) error {
err := d.rpcCall(&methodCall{ err := d.rpcCall(&methodCall{
MethodName: "domain.zone.delete", MethodName: "domain.zone.delete",
Params: []param{ Params: []param{
paramString{Value: d.apiKey}, paramString{Value: d.config.APIKey},
paramInt{Value: zoneID}, paramInt{Value: zoneID},
}, },
}, resp) }, resp)
@ -493,7 +430,22 @@ func (d *DNSProvider) deleteZone(zoneID int) error {
} }
if !resp.Value { if !resp.Value {
return fmt.Errorf("Gandi DNS: could not delete zone_id") return fmt.Errorf("could not delete zone_id")
} }
return nil 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
}

View file

@ -15,12 +15,8 @@ import (
// TestDNSProvider runs Present and CleanUp against a fake Gandi RPC // TestDNSProvider runs Present and CleanUp against a fake Gandi RPC
// Server, whose responses are predetermined for particular requests. // Server, whose responses are predetermined for particular requests.
func TestDNSProvider(t *testing.T) { func TestDNSProvider(t *testing.T) {
fakeAPIKey := "123412341234123412341234"
fakeKeyAuth := "XXXX" fakeKeyAuth := "XXXX"
provider, err := NewDNSProviderCredentials(fakeAPIKey)
require.NoError(t, err)
regexpDate, err := regexp.Compile(`\[ACME Challenge [^\]:]*:[^\]]*\]`) regexpDate, err := regexp.Compile(`\[ACME Challenge [^\]:]*:[^\]]*\]`)
require.NoError(t, err) require.NoError(t, err)
@ -45,13 +41,19 @@ func TestDNSProvider(t *testing.T) {
return "example.com.", nil return "example.com.", nil
} }
// override gandi endpoint and findZoneByFqdn function config := NewDefaultConfig()
savedEndpoint, savedFindZoneByFqdn := endpoint, findZoneByFqdn config.BaseURL = fakeServer.URL + "/"
defer func() { config.APIKey = "123412341234123412341234"
endpoint, findZoneByFqdn = savedEndpoint, savedFindZoneByFqdn
}()
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 // run Present
err = provider.Present("abc.def.example.com", "", fakeKeyAuth) err = provider.Present("abc.def.example.com", "", fakeKeyAuth)

View file

@ -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"`
}

View file

@ -5,6 +5,7 @@ package gandiv5
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@ -18,30 +19,51 @@ import (
// Gandi API reference: http://doc.livedns.gandi.net/ // Gandi API reference: http://doc.livedns.gandi.net/
var ( const (
// endpoint is the Gandi API endpoint used by Present and // defaultBaseURL endpoint is the Gandi API endpoint used by Present and CleanUp.
// CleanUp. It is overridden during tests. defaultBaseURL = "https://dns.api.gandi.net/api/v5"
endpoint = "https://dns.api.gandi.net/api/v5" minTTL = 300
// findZoneByFqdn determines the DNS zone of an fqdn. It is overridden
// during tests.
findZoneByFqdn = acme.FindZoneByFqdn
) )
// 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 // inProgressInfo contains information about an in-progress challenge
type inProgressInfo struct { type inProgressInfo struct {
fieldName string fieldName string
authZone 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 // DNSProvider is an implementation of the
// acme.ChallengeProviderTimeout interface that uses Gandi's LiveDNS // acme.ChallengeProviderTimeout interface that uses Gandi's LiveDNS
// API to manage TXT records for a domain. // API to manage TXT records for a domain.
type DNSProvider struct { type DNSProvider struct {
apiKey string config *Config
inProgressFQDNs map[string]inProgressInfo inProgressFQDNs map[string]inProgressInfo
inProgressMu sync.Mutex inProgressMu sync.Mutex
client *http.Client
} }
// NewDNSProvider returns a DNSProvider instance configured for Gandi. // NewDNSProvider returns a DNSProvider instance configured for Gandi.
@ -49,43 +71,63 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("GANDIV5_API_KEY") values, err := env.Get("GANDIV5_API_KEY")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for Gandi. // to return a DNSProvider instance configured for Gandi.
// Deprecated
func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) {
if apiKey == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("Gandi DNS: No Gandi API Key given") 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{ return &DNSProvider{
apiKey: apiKey, config: config,
inProgressFQDNs: make(map[string]inProgressInfo), inProgressFQDNs: make(map[string]inProgressInfo),
client: &http.Client{Timeout: 10 * time.Second},
}, nil }, nil
} }
// Present creates a TXT record using the specified parameters. // Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
if ttl < 300 {
ttl = 300 // 300 is gandi minimum value for ttl if d.config.TTL < minTTL {
d.config.TTL = minTTL // 300 is gandi minimum value for ttl
} }
// find authZone // find authZone
authZone, err := findZoneByFqdn(fqdn, acme.RecursiveNameservers) authZone, err := findZoneByFqdn(fqdn, acme.RecursiveNameservers)
if err != nil { 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 // determine name of TXT record
if !strings.HasSuffix( if !strings.HasSuffix(
strings.ToLower(fqdn), strings.ToLower("."+authZone)) { strings.ToLower(fqdn), strings.ToLower("."+authZone)) {
return fmt.Errorf( return fmt.Errorf("gandiv5: unexpected authZone %s for fqdn %s", authZone, fqdn)
"Gandi DNS: unexpected authZone %s for fqdn %s", authZone, fqdn)
} }
name := fqdn[:len(fqdn)-len("."+authZone)] name := fqdn[:len(fqdn)-len("."+authZone)]
@ -95,7 +137,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
defer d.inProgressMu.Unlock() defer d.inProgressMu.Unlock()
// add TXT record into authZone // 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 { if err != nil {
return err return err
} }
@ -125,37 +167,47 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
delete(d.inProgressFQDNs, fqdn) delete(d.inProgressFQDNs, fqdn)
// delete TXT record from authZone // 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 // Timeout returns the values (20*time.Minute, 20*time.Second) which
// are used by the acme package as timeout and check interval values // are used by the acme package as timeout and check interval values
// when checking for DNS record propagation with Gandi. // when checking for DNS record propagation with Gandi.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { 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 { func (d *DNSProvider) addTXTRecord(domain string, name string, value string, ttl int) error {
RRSetTTL int `json:"rrset_ttl"` target := fmt.Sprintf("domains/%s/records/%s/TXT", domain, name)
RRSetValues []string `json:"rrset_values"` 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 { func (d *DNSProvider) deleteTXTRecord(domain string, name string) error {
Delete bool `json:"delete"` 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) { 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) body, err := json.Marshal(payload)
if err != nil { if err != nil {
@ -168,19 +220,20 @@ func (d *DNSProvider) sendRequest(method string, resource string, payload interf
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if len(d.apiKey) > 0 { if len(d.config.APIKey) > 0 {
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 { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { 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 var response responseStruct
err = json.NewDecoder(resp.Body).Decode(&response) err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil && method != http.MethodDelete { if err != nil && method != http.MethodDelete {
@ -189,28 +242,3 @@ func (d *DNSProvider) sendRequest(method string, resource string, payload interf
return &response, nil 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
}

View file

@ -15,12 +15,8 @@ import (
// TestDNSProvider runs Present and CleanUp against a fake Gandi RPC // TestDNSProvider runs Present and CleanUp against a fake Gandi RPC
// Server, whose responses are predetermined for particular requests. // Server, whose responses are predetermined for particular requests.
func TestDNSProvider(t *testing.T) { func TestDNSProvider(t *testing.T) {
fakeAPIKey := "123412341234123412341234"
fakeKeyAuth := "XXXX" fakeKeyAuth := "XXXX"
provider, err := NewDNSProviderCredentials(fakeAPIKey)
require.NoError(t, err)
regexpToken, err := regexp.Compile(`"rrset_values":\[".+"\]`) regexpToken, err := regexp.Compile(`"rrset_values":\[".+"\]`)
require.NoError(t, err) require.NoError(t, err)
@ -46,13 +42,19 @@ func TestDNSProvider(t *testing.T) {
return "example.com.", nil return "example.com.", nil
} }
// override gandi endpoint and findZoneByFqdn function config := NewDefaultConfig()
savedEndpoint, savedFindZoneByFqdn := endpoint, findZoneByFqdn config.APIKey = "123412341234123412341234"
defer func() { config.BaseURL = fakeServer.URL
endpoint, findZoneByFqdn = savedEndpoint, savedFindZoneByFqdn
}()
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 // run Present
err = provider.Present("abc.def.example.com", "", fakeKeyAuth) err = provider.Present("abc.def.example.com", "", fakeKeyAuth)

View file

@ -4,29 +4,47 @@ package gcloud
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http"
"os" "os"
"time" "time"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
"github.com/xenolf/lego/platform/config/env"
"golang.org/x/net/context" "golang.org/x/net/context"
"golang.org/x/oauth2/google" "golang.org/x/oauth2/google"
"google.golang.org/api/dns/v1" "google.golang.org/api/dns/v1"
) )
// 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
}
// 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. // DNSProvider is an implementation of the DNSProvider interface.
type DNSProvider struct { type DNSProvider struct {
project string config *Config
client *dns.Service client *dns.Service
} }
// NewDNSProvider returns a DNSProvider instance configured for Google Cloud // NewDNSProvider returns a DNSProvider instance configured for Google Cloud DNS.
// DNS. Project name must be passed in the environment variable: GCE_PROJECT. // Project name must be passed in the environment variable: GCE_PROJECT.
// A Service Account file can be passed in the environment variable: // A Service Account file can be passed in the environment variable: GCE_SERVICE_ACCOUNT_FILE
// GCE_SERVICE_ACCOUNT_FILE
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
if saFile, ok := os.LookupEnv("GCE_SERVICE_ACCOUNT_FILE"); ok { if saFile, ok := os.LookupEnv("GCE_SERVICE_ACCOUNT_FILE"); ok {
return NewDNSProviderServiceAccount(saFile) return NewDNSProviderServiceAccount(saFile)
@ -36,37 +54,35 @@ func NewDNSProvider() (*DNSProvider, error) {
return NewDNSProviderCredentials(project) return NewDNSProviderCredentials(project)
} }
// NewDNSProviderCredentials uses the supplied credentials to return a // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for Google Cloud DNS. // to return a DNSProvider instance configured for Google Cloud DNS.
func NewDNSProviderCredentials(project string) (*DNSProvider, error) { func NewDNSProviderCredentials(project string) (*DNSProvider, error) {
if project == "" { 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) client, err := google.DefaultClient(context.Background(), dns.NdevClouddnsReadwriteScope)
if err != nil { 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 { config := NewDefaultConfig()
return nil, fmt.Errorf("unable to create Google Cloud DNS service: %v", err) config.Project = project
} config.HTTPClient = client
return &DNSProvider{
project: project, return NewDNSProviderConfig(config)
client: svc,
}, nil
} }
// NewDNSProviderServiceAccount uses the supplied service account JSON file to // NewDNSProviderServiceAccount uses the supplied service account JSON file
// return a DNSProvider instance configured for Google Cloud DNS. // to return a DNSProvider instance configured for Google Cloud DNS.
func NewDNSProviderServiceAccount(saFile string) (*DNSProvider, error) { func NewDNSProviderServiceAccount(saFile string) (*DNSProvider, error) {
if saFile == "" { 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) dat, err := ioutil.ReadFile(saFile)
if err != nil { 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 // read project id from service account file
@ -75,39 +91,50 @@ func NewDNSProviderServiceAccount(saFile string) (*DNSProvider, error) {
} }
err = json.Unmarshal(dat, &datJSON) err = json.Unmarshal(dat, &datJSON)
if err != nil || datJSON.ProjectID == "" { 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 project := datJSON.ProjectID
conf, err := google.JWTConfigFromJSON(dat, dns.NdevClouddnsReadwriteScope) conf, err := google.JWTConfigFromJSON(dat, dns.NdevClouddnsReadwriteScope)
if err != nil { 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()) client := conf.Client(context.Background())
svc, err := dns.New(client) config := NewDefaultConfig()
if err != nil { config.Project = project
return nil, fmt.Errorf("unable to create Google Cloud DNS service: %v", err) 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, svc, err := dns.New(config.HTTPClient)
client: svc, if err != nil {
}, 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. // Present creates a TXT record to fulfil the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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) zone, err := d.getHostedZone(domain)
if err != nil { if err != nil {
return err return fmt.Errorf("googlecloud: %v", err)
} }
rec := &dns.ResourceRecordSet{ rec := &dns.ResourceRecordSet{
Name: fqdn, Name: fqdn,
Rrdatas: []string{value}, Rrdatas: []string{value},
Ttl: int64(ttl), Ttl: int64(d.config.TTL),
Type: "TXT", Type: "TXT",
} }
change := &dns.Change{ change := &dns.Change{
@ -117,25 +144,25 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// Look for existing records. // Look for existing records.
existing, err := d.findTxtRecords(zone, fqdn) existing, err := d.findTxtRecords(zone, fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("googlecloud: %v", err)
} }
if len(existing) > 0 { if len(existing) > 0 {
// Attempt to delete the existing records when adding our new one. // Attempt to delete the existing records when adding our new one.
change.Deletions = existing 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 { if err != nil {
return err return fmt.Errorf("googlecloud: %v", err)
} }
// wait for change to be acknowledged // wait for change to be acknowledged
for chg.Status == "pending" { for chg.Status == "pending" {
time.Sleep(time.Second) 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 { 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) zone, err := d.getHostedZone(domain)
if err != nil { if err != nil {
return err return fmt.Errorf("googlecloud: %v", err)
} }
records, err := d.findTxtRecords(zone, fqdn) records, err := d.findTxtRecords(zone, fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("googlecloud: %v", err)
} }
if len(records) == 0 { if len(records) == 0 {
return nil return nil
} }
_, err = d.client.Changes.Create(d.project, zone, &dns.Change{Deletions: records}).Do() _, err = d.client.Changes.Create(d.config.Project, zone, &dns.Change{Deletions: records}).Do()
return err return fmt.Errorf("googlecloud: %v", err)
} }
// Timeout customizes the timeout values used by the ACME package for checking // Timeout customizes the timeout values used by the ACME package for checking
// DNS record validity. // DNS record validity.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { 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 // getHostedZone returns the managed-zone
@ -178,23 +205,22 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) {
} }
zones, err := d.client.ManagedZones. zones, err := d.client.ManagedZones.
List(d.project). List(d.config.Project).
DnsName(authZone). DnsName(authZone).
Do() Do()
if err != nil { 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 { 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 return zones.ManagedZones[0].Name, nil
} }
func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*dns.ResourceRecordSet, error) { func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*dns.ResourceRecordSet, error) {
recs, err := d.client.ResourceRecordSets.List(d.config.Project, zone).Name(fqdn).Type("TXT").Do()
recs, err := d.client.ResourceRecordSets.List(d.project, zone).Name(fqdn).Type("TXT").Do()
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -60,7 +60,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("GCE_PROJECT", "") os.Setenv("GCE_PROJECT", "")
_, err := NewDNSProvider() _, err := NewDNSProvider()
assert.EqualError(t, err, "Google Cloud project name missing") assert.EqualError(t, err, "googlecloud: project name missing")
} }
func TestLiveGoogleCloudPresent(t *testing.T) { func TestLiveGoogleCloudPresent(t *testing.T) {

View file

@ -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"`
}

View file

@ -5,6 +5,7 @@ package glesys
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@ -18,64 +19,102 @@ import (
// GleSYS API reference: https://github.com/GleSYS/API/wiki/API-Documentation // GleSYS API reference: https://github.com/GleSYS/API/wiki/API-Documentation
// domainAPI is the GleSYS API endpoint used by Present and CleanUp. const (
const domainAPI = "https://api.glesys.com/domain" // 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 // DNSProvider is an implementation of the
// acme.ChallengeProviderTimeout interface that uses GleSYS // acme.ChallengeProviderTimeout interface that uses GleSYS
// API to manage TXT records for a domain. // API to manage TXT records for a domain.
type DNSProvider struct { type DNSProvider struct {
apiUser string config *Config
apiKey string
activeRecords map[string]int activeRecords map[string]int
inProgressMu sync.Mutex inProgressMu sync.Mutex
client *http.Client
} }
// NewDNSProvider returns a DNSProvider instance configured for GleSYS. // NewDNSProvider returns a DNSProvider instance configured for GleSYS.
// Credentials must be passed in the environment variables: GLESYS_API_USER // Credentials must be passed in the environment variables:
// and GLESYS_API_KEY. // GLESYS_API_USER and GLESYS_API_KEY.
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("GLESYS_API_USER", "GLESYS_API_KEY") values, err := env.Get("GLESYS_API_USER", "GLESYS_API_KEY")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for GleSYS. // to return a DNSProvider instance configured for GleSYS.
// Deprecated
func NewDNSProviderCredentials(apiUser string, apiKey string) (*DNSProvider, error) { func NewDNSProviderCredentials(apiUser string, apiKey string) (*DNSProvider, error) {
if apiUser == "" || apiKey == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("GleSYS DNS: Incomplete credentials provided") 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{ return &DNSProvider{
apiUser: apiUser,
apiKey: apiKey,
activeRecords: make(map[string]int), activeRecords: make(map[string]int),
client: &http.Client{Timeout: 10 * time.Second},
}, nil }, nil
} }
// Present creates a TXT record using the specified parameters. // Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
if ttl < 60 {
ttl = 60 // 60 is GleSYS minimum value for ttl if d.config.TTL < minTTL {
d.config.TTL = minTTL // 60 is GleSYS minimum value for ttl
} }
// find authZone // find authZone
authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers)
if err != nil { 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 // determine name of TXT record
if !strings.HasSuffix( if !strings.HasSuffix(
strings.ToLower(fqdn), strings.ToLower("."+authZone)) { strings.ToLower(fqdn), strings.ToLower("."+authZone)) {
return fmt.Errorf( return fmt.Errorf("glesys: unexpected authZone %s for fqdn %s", authZone, fqdn)
"GleSYS DNS: unexpected authZone %s for fqdn %s", authZone, fqdn)
} }
name := fqdn[:len(fqdn)-len("."+authZone)] name := fqdn[:len(fqdn)-len("."+authZone)]
@ -85,7 +124,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
defer d.inProgressMu.Unlock() defer d.inProgressMu.Unlock()
// add TXT record into authZone // 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 { if err != nil {
return err 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 // are used by the acme package as timeout and check interval values
// when checking for DNS record propagation with GleSYS. // when checking for DNS record propagation with GleSYS.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { 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, 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"`
} }
// POSTing/Marshalling/Unmarshalling // POSTing/Marshalling/Unmarshalling
func (d *DNSProvider) sendRequest(method string, resource string, payload interface{}) (*responseStruct, error) { 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) body, err := json.Marshal(payload)
if err != nil { 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.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 { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { 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 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 { 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 response.Response.Record.RecordID, nil
} }
return 0, err return 0, err
@ -201,7 +217,7 @@ func (d *DNSProvider) deleteTXTRecord(fqdn string, recordid int) error {
RecordID: recordid, RecordID: recordid,
}) })
if response != nil && response.Response.Status.Code == 200 { 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 return err
} }

View file

@ -4,6 +4,7 @@ package godaddy
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
@ -15,46 +16,83 @@ import (
"github.com/xenolf/lego/platform/config/env" "github.com/xenolf/lego/platform/config/env"
) )
// GoDaddyAPIURL represents the API endpoint to call. const (
const apiURL = "https://api.godaddy.com" // 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 // DNSProvider is an implementation of the acme.ChallengeProvider interface
type DNSProvider struct { type DNSProvider struct {
apiKey string config *Config
apiSecret string
client *http.Client
} }
// NewDNSProvider returns a DNSProvider instance configured for godaddy. // NewDNSProvider returns a DNSProvider instance configured for godaddy.
// Credentials must be passed in the environment variables: GODADDY_API_KEY // Credentials must be passed in the environment variables:
// and GODADDY_API_SECRET. // GODADDY_API_KEY and GODADDY_API_SECRET.
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("GODADDY_API_KEY", "GODADDY_API_SECRET") values, err := env.Get("GODADDY_API_KEY", "GODADDY_API_SECRET")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for godaddy. // to return a DNSProvider instance configured for godaddy.
// Deprecated
func NewDNSProviderCredentials(apiKey, apiSecret string) (*DNSProvider, error) { func NewDNSProviderCredentials(apiKey, apiSecret string) (*DNSProvider, error) {
if apiKey == "" || apiSecret == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("GoDaddy credentials missing") 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{ if config.APIKey == "" || config.APISecret == "" {
apiKey: apiKey, return nil, fmt.Errorf("godaddy: credentials missing")
apiSecret: apiSecret, }
client: &http.Client{Timeout: 30 * time.Second},
}, nil return &DNSProvider{config: config}, nil
} }
// Timeout returns the timeout and interval to use when checking for DNS // Timeout returns the timeout and interval to use when checking for DNS
// propagation. Adjusting here to cope with spikes in propagation times. // propagation. Adjusting here to cope with spikes in propagation times.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { 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 { 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 // Present creates a TXT record to fulfil the dns-01 challenge
func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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) domainZone, err := d.getZone(fqdn)
if err != nil { if err != nil {
return err return err
} }
if ttl < 600 { if d.config.TTL < minTTL {
ttl = 600 d.config.TTL = minTTL
} }
recordName := d.extractRecordName(fqdn, domainZone) recordName := d.extractRecordName(fqdn, domainZone)
@ -83,7 +121,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
Type: "TXT", Type: "TXT",
Name: recordName, Name: recordName,
Data: value, 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) { 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 { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "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 // DNSRecord a DNS record

View file

@ -3,6 +3,7 @@ package iij
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@ -17,6 +18,18 @@ type Config struct {
AccessKey string AccessKey string
SecretKey string SecretKey string
DoServiceCode 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 // DNSProvider implements the acme.ChallengeProvider interface
@ -29,19 +42,24 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("IIJ_API_ACCESS_KEY", "IIJ_API_SECRET_KEY", "IIJ_DO_SERVICE_CODE") values, err := env.Get("IIJ_API_ACCESS_KEY", "IIJ_API_SECRET_KEY", "IIJ_DO_SERVICE_CODE")
if err != nil { if err != nil {
return nil, fmt.Errorf("IIJ: %v", err) return nil, fmt.Errorf("iij: %v", err)
} }
return NewDNSProviderConfig(&Config{ config := NewDefaultConfig()
AccessKey: values["IIJ_API_ACCESS_KEY"], config.AccessKey = values["IIJ_API_ACCESS_KEY"]
SecretKey: values["IIJ_API_SECRET_KEY"], config.SecretKey = values["IIJ_API_SECRET_KEY"]
DoServiceCode: values["IIJ_DO_SERVICE_CODE"], config.DoServiceCode = values["IIJ_DO_SERVICE_CODE"]
})
return NewDNSProviderConfig(config)
} }
// NewDNSProviderConfig takes a given config ans returns a custom configured // NewDNSProviderConfig takes a given config
// DNSProvider instance // and returns a custom configured DNSProvider instance
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config.SecretKey == "" || config.AccessKey == "" || config.DoServiceCode == "" {
return nil, fmt.Errorf("iij: credentials missing")
}
return &DNSProvider{ return &DNSProvider{
api: doapi.NewAPI(config.AccessKey, config.SecretKey), api: doapi.NewAPI(config.AccessKey, config.SecretKey),
config: config, 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. // Timeout returns the timeout and interval to use when checking for DNS propagation.
func (p *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return time.Minute * 2, time.Second * 4 return d.config.PropagationTimeout, d.config.PollingInterval
} }
// Present creates a TXT record using the specified parameters // 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) _, 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 // 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) _, 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 { func (d *DNSProvider) addTxtRecord(domain, value string) error {
zones, err := p.listZones() zones, err := d.listZones()
if err != nil { if err != nil {
return err return err
} }
@ -77,25 +99,25 @@ func (p *DNSProvider) addTxtRecord(domain, value string) error {
} }
request := protocol.RecordAdd{ request := protocol.RecordAdd{
DoServiceCode: p.config.DoServiceCode, DoServiceCode: d.config.DoServiceCode,
ZoneName: zone, ZoneName: zone,
Owner: owner, Owner: owner,
TTL: "300", TTL: strconv.Itoa(d.config.TTL),
RecordType: "TXT", RecordType: "TXT",
RData: value, RData: value,
} }
response := &protocol.RecordAddResponse{} 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 err
} }
return p.commit() return d.commit()
} }
func (p *DNSProvider) deleteTxtRecord(domain, value string) error { func (d *DNSProvider) deleteTxtRecord(domain, value string) error {
zones, err := p.listZones() zones, err := d.listZones()
if err != nil { if err != nil {
return err return err
} }
@ -105,45 +127,45 @@ func (p *DNSProvider) deleteTxtRecord(domain, value string) error {
return err return err
} }
id, err := p.findTxtRecord(owner, zone, value) id, err := d.findTxtRecord(owner, zone, value)
if err != nil { if err != nil {
return err return err
} }
request := protocol.RecordDelete{ request := protocol.RecordDelete{
DoServiceCode: p.config.DoServiceCode, DoServiceCode: d.config.DoServiceCode,
ZoneName: zone, ZoneName: zone,
RecordID: id, RecordID: id,
} }
response := &protocol.RecordDeleteResponse{} 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 err
} }
return p.commit() return d.commit()
} }
func (p *DNSProvider) commit() error { func (d *DNSProvider) commit() error {
request := protocol.Commit{ request := protocol.Commit{
DoServiceCode: p.config.DoServiceCode, DoServiceCode: d.config.DoServiceCode,
} }
response := &protocol.CommitResponse{} 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{ request := protocol.RecordListGet{
DoServiceCode: p.config.DoServiceCode, DoServiceCode: d.config.DoServiceCode,
ZoneName: zone, ZoneName: zone,
} }
response := &protocol.RecordListGetResponse{} 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 return "", err
} }
@ -162,14 +184,14 @@ func (p *DNSProvider) findTxtRecord(owner, zone, value string) (string, error) {
return id, nil return id, nil
} }
func (p *DNSProvider) listZones() ([]string, error) { func (d *DNSProvider) listZones() ([]string, error) {
request := protocol.ZoneListGet{ request := protocol.ZoneListGet{
DoServiceCode: p.config.DoServiceCode, DoServiceCode: d.config.DoServiceCode,
} }
response := &protocol.ZoneListGetResponse{} 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 return nil, err
} }

View file

@ -95,7 +95,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("IIJ_DO_SERVICE_CODE", "") os.Setenv("IIJ_DO_SERVICE_CODE", "")
_, err := NewDNSProvider() _, 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) { func TestNewDNSProvider(t *testing.T) {

View file

@ -3,6 +3,8 @@
package lightsail package lightsail
import ( import (
"errors"
"fmt"
"math/rand" "math/rand"
"os" "os"
"time" "time"
@ -13,21 +15,15 @@ import (
"github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/lightsail" "github.com/aws/aws-sdk-go/service/lightsail"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
"github.com/xenolf/lego/platform/config/env"
) )
const ( const (
maxRetries = 5 maxRetries = 5
) )
// DNSProvider implements the acme.ChallengeProvider interface // customRetryer implements the client.Retryer interface by composing the DefaultRetryer.
type DNSProvider struct { // It controls the logic for retrying recoverable request errors (e.g. when rate limits are exceeded).
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).
type customRetryer struct { type customRetryer struct {
client.DefaultRetryer client.DefaultRetryer
} }
@ -47,13 +43,36 @@ func (c customRetryer) RetryRules(r *request.Request) time.Duration {
return time.Duration(delay) * time.Millisecond return time.Duration(delay) * time.Millisecond
} }
// NewDNSProvider returns a DNSProvider instance configured for the AWS // Config is used to configure the creation of the DNSProvider
// Lightsail service. 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 // AWS Credentials are automatically detected in the following locations
// and prioritized in the following order: // and prioritized in the following order:
// 1. Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, // 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) // 2. Shared credentials file (defaults to ~/.aws/credentials)
// 3. Amazon EC2 IAM role // 3. Amazon EC2 IAM role
// //
@ -61,17 +80,26 @@ func (c customRetryer) RetryRules(r *request.Request) time.Duration {
// //
// See also: https://github.com/aws/aws-sdk-go/wiki/configuring-sdk // See also: https://github.com/aws/aws-sdk-go/wiki/configuring-sdk
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
r := customRetryer{} return NewDNSProviderConfig(NewDefaultConfig())
r.NumMaxRetries = maxRetries }
config := aws.NewConfig().WithRegion("us-east-1") // NewDNSProviderConfig return a DNSProvider instance configured for AWS Lightsail.
sess, err := session.NewSession(request.WithRetryer(config, r)) 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 { if err != nil {
return nil, err return nil, err
} }
return &DNSProvider{ return &DNSProvider{
dnsZone: os.Getenv("DNS_ZONE"), config: config,
client: lightsail.New(sess), client: lightsail.New(sess),
}, nil }, nil
} }
@ -79,31 +107,43 @@ func NewDNSProvider() (*DNSProvider, error) {
// Present creates a TXT record using the specified parameters // Present creates a TXT record using the specified parameters
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, _ := acme.DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
value = `"` + value + `"`
err := d.newTxtRecord(domain, fqdn, value) err := d.newTxtRecord(domain, fqdn, `"`+value+`"`)
return err if err != nil {
return fmt.Errorf("lightsail: %v", err)
}
return nil
} }
// CleanUp removes the TXT record matching the specified parameters // CleanUp removes the TXT record matching the specified parameters
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, value, _ := acme.DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
value = `"` + value + `"`
params := &lightsail.DeleteDomainEntryInput{ params := &lightsail.DeleteDomainEntryInput{
DomainName: aws.String(d.dnsZone), DomainName: aws.String(d.config.DNSZone),
DomainEntry: &lightsail.DomainEntry{ DomainEntry: &lightsail.DomainEntry{
Name: aws.String(fqdn), Name: aws.String(fqdn),
Type: aws.String("TXT"), Type: aws.String("TXT"),
Target: aws.String(value), Target: aws.String(`"` + value + `"`),
}, },
} }
_, err := d.client.DeleteDomainEntry(params) _, 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 { func (d *DNSProvider) newTxtRecord(domain string, fqdn string, value string) error {
params := &lightsail.CreateDomainEntryInput{ params := &lightsail.CreateDomainEntryInput{
DomainName: aws.String(d.dnsZone), DomainName: aws.String(d.config.DNSZone),
DomainEntry: &lightsail.DomainEntry{ DomainEntry: &lightsail.DomainEntry{
Name: aws.String(fqdn), Name: aws.String(fqdn),
Target: aws.String(value), Target: aws.String(value),

View file

@ -43,8 +43,10 @@ func makeLightsailProvider(ts *httptest.Server) (*DNSProvider, error) {
return nil, err return nil, err
} }
conf := NewDefaultConfig()
client := lightsail.New(sess) client := lightsail.New(sess)
return &DNSProvider{client: client}, nil return &DNSProvider{client: client, config: conf}, nil
} }
func TestCredentialsFromEnv(t *testing.T) { func TestCredentialsFromEnv(t *testing.T) {

View file

@ -19,6 +19,21 @@ const (
dnsUpdateFudgeSecs = 120 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 { type hostedZoneInfo struct {
domainID int domainID int
resourceName string resourceName string
@ -26,6 +41,7 @@ type hostedZoneInfo struct {
// DNSProvider implements the acme.ChallengeProvider interface. // DNSProvider implements the acme.ChallengeProvider interface.
type DNSProvider struct { type DNSProvider struct {
config *Config
client *dns.DNS client *dns.DNS
} }
@ -34,27 +50,44 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("LINODE_API_KEY") values, err := env.Get("LINODE_API_KEY")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for Linode. // to return a DNSProvider instance configured for Linode.
// Deprecated
func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) {
if len(apiKey) == 0 { config := NewDefaultConfig()
return nil, errors.New("Linode credentials missing") 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{ return &DNSProvider{
client: dns.New(apiKey), config: config,
client: dns.New(config.APIKey),
}, nil }, nil
} }
// Timeout returns the timeout and interval to use when checking for DNS // Timeout returns the timeout and interval to use when checking for DNS
// propagation. Adjusting here to cope with spikes in propagation times. // 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 // 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 // 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 // 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) + timeout = (time.Duration(minsRemaining) * time.Minute) +
(dnsMinTTLSecs * time.Second) + (dnsMinTTLSecs * time.Second) +
(dnsUpdateFudgeSecs * time.Second) (dnsUpdateFudgeSecs * time.Second)
interval = 15 * time.Second interval = d.config.PollingInterval
return return
} }
// Present creates a TXT record using the specified parameters. // 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) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
zone, err := p.getHostedZoneInfo(fqdn) zone, err := d.getHostedZoneInfo(fqdn)
if err != nil { if err != nil {
return err 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 return err
} }
@ -85,15 +118,15 @@ func (p *DNSProvider) Present(domain, token, keyAuth string) error {
} }
// CleanUp removes the TXT record matching the specified parameters. // 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) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
zone, err := p.getHostedZoneInfo(fqdn) zone, err := d.getHostedZoneInfo(fqdn)
if err != nil { if err != nil {
return err return err
} }
// Get all TXT records for the specified domain. // 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 { if err != nil {
return err return err
} }
@ -101,7 +134,7 @@ func (p *DNSProvider) CleanUp(domain, token, keyAuth string) error {
// Remove the specified resource, if it exists. // Remove the specified resource, if it exists.
for _, resource := range resources { for _, resource := range resources {
if resource.Name == zone.resourceName && resource.Target == value { 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 { if err != nil {
return err return err
} }
@ -115,16 +148,17 @@ func (p *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil 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. // Lookup the zone that handles the specified FQDN.
authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resourceName := strings.TrimSuffix(fqdn, "."+authZone) resourceName := strings.TrimSuffix(fqdn, "."+authZone)
// Query the authority zone. // Query the authority zone.
domain, err := p.client.GetDomain(acme.UnFqdn(authZone)) domain, err := d.client.GetDomain(acme.UnFqdn(authZone))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -86,17 +86,22 @@ func TestNewDNSProviderWithoutEnv(t *testing.T) {
os.Setenv("LINODE_API_KEY", "") os.Setenv("LINODE_API_KEY", "")
_, err := NewDNSProvider() _, 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) { func TestNewDNSProviderCredentialsWithKey(t *testing.T) {
_, err := NewDNSProviderCredentials("testing") config := NewDefaultConfig()
config.APIKey = "testing"
_, err := NewDNSProviderConfig(config)
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestNewDNSProviderCredentialsWithoutKey(t *testing.T) { func TestNewDNSProviderCredentialsWithoutKey(t *testing.T) {
_, err := NewDNSProviderCredentials("") config := NewDefaultConfig()
assert.EqualError(t, err, "Linode credentials missing")
_, err := NewDNSProviderConfig(config)
assert.EqualError(t, err, "linode: credentials missing")
} }
func TestDNSProvider_Present(t *testing.T) { func TestDNSProvider_Present(t *testing.T) {

View file

@ -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"`
}

View file

@ -4,10 +4,12 @@ package namecheap
import ( import (
"encoding/xml" "encoding/xml"
"errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time" "time"
@ -29,84 +31,175 @@ import (
// address as a form or query string value. This code uses a namecheap // address as a form or query string value. This code uses a namecheap
// service to query the client's IP address. // service to query the client's IP address.
var ( const (
debug = false
defaultBaseURL = "https://api.namecheap.com/xml.response" defaultBaseURL = "https://api.namecheap.com/xml.response"
getIPURL = "https://dynamicdns.park-your-domain.com/getip" 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 // DNSProvider is an implementation of the ChallengeProviderTimeout interface
// that uses Namecheap's tool API to manage TXT records for a domain. // that uses Namecheap's tool API to manage TXT records for a domain.
type DNSProvider struct { type DNSProvider struct {
baseURL string config *Config
apiUser string
apiKey string
clientIP string
client *http.Client
} }
// NewDNSProvider returns a DNSProvider instance configured for namecheap. // NewDNSProvider returns a DNSProvider instance configured for namecheap.
// Credentials must be passed in the environment variables: NAMECHEAP_API_USER // Credentials must be passed in the environment variables:
// and NAMECHEAP_API_KEY. // NAMECHEAP_API_USER and NAMECHEAP_API_KEY.
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("NAMECHEAP_API_USER", "NAMECHEAP_API_KEY") values, err := env.Get("NAMECHEAP_API_USER", "NAMECHEAP_API_KEY")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for namecheap. // to return a DNSProvider instance configured for namecheap.
// Deprecated
func NewDNSProviderCredentials(apiUser, apiKey string) (*DNSProvider, error) { func NewDNSProviderCredentials(apiUser, apiKey string) (*DNSProvider, error) {
if apiUser == "" || apiKey == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("Namecheap credentials missing") config.APIUser = apiUser
config.APIKey = apiKey
return NewDNSProviderConfig(config)
}
// 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")
} }
client := &http.Client{Timeout: 60 * time.Second} if config.APIUser == "" || config.APIKey == "" {
return nil, fmt.Errorf("namecheap: credentials missing")
}
clientIP, err := getClientIP(client) if len(config.ClientIP) == 0 {
clientIP, err := getClientIP(config.HTTPClient, config.Debug)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("namecheap: %v", err)
}
config.ClientIP = clientIP
} }
return &DNSProvider{ return &DNSProvider{config: config}, nil
baseURL: defaultBaseURL,
apiUser: apiUser,
apiKey: apiKey,
clientIP: clientIP,
client: client,
}, nil
} }
// Timeout returns the timeout and interval to use when checking for DNS // Timeout returns the timeout and interval to use when checking for DNS propagation.
// propagation. Namecheap can sometimes take a long time to complete an // Namecheap can sometimes take a long time to complete an update, so wait up to 60 minutes for the update to propagate.
// update, so wait up to 60 minutes for the update to propagate.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { 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. // Present installs a TXT record for the DNS challenge.
// Namecheap uses the term "host" to refer to all DNS records that include func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// a host field (A, AAAA, CNAME, NS, TXT, URL). tlds, err := d.getTLDs()
type host struct { if err != nil {
Type string `xml:",attr"` return fmt.Errorf("namecheap: %v", err)
Name string `xml:",attr"` }
Address string `xml:",attr"`
MXPref string `xml:",attr"` ch, err := newChallenge(domain, keyAuth, tlds)
TTL string `xml:",attr"` 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. // CleanUp removes a TXT record used for a previous DNS challenge.
type apierror struct { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
Number int `xml:",attr"` tlds, err := d.getTLDs()
Description string `xml:",innerxml"` 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 // getClientIP returns the client's public IP address.
// IP discovery service to perform the lookup. // It uses namecheap's IP discovery service to perform the lookup.
func getClientIP(client *http.Client) (addr string, err error) { func getClientIP(client *http.Client, debug bool) (addr string, err error) {
resp, err := client.Get(getIPURL) resp, err := client.Get(getIPURL)
if err != nil { if err != nil {
return "", err return "", err
@ -124,18 +217,6 @@ func getClientIP(client *http.Client) (addr string, err error) {
return string(clientIP), nil 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 // newChallenge builds a challenge record from a domain name, a challenge
// authentication key, and a map of available TLDs. // authentication key, and a map of available TLDs.
func newChallenge(domain, keyAuth string, tlds map[string]string) (*challenge, error) { 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 // setGlobalParams adds the namecheap global parameters to the provided url
// Values record. // Values record.
func (d *DNSProvider) setGlobalParams(v *url.Values, cmd string) { func (d *DNSProvider) setGlobalParams(v *url.Values, cmd string) {
v.Set("ApiUser", d.apiUser) v.Set("ApiUser", d.config.APIUser)
v.Set("ApiKey", d.apiKey) v.Set("ApiKey", d.config.APIKey)
v.Set("UserName", d.apiUser) v.Set("UserName", d.config.APIUser)
v.Set("ClientIp", d.clientIP)
v.Set("Command", cmd) v.Set("Command", cmd)
v.Set("ClientIp", d.config.ClientIP)
} }
// getTLDs requests the list of available TLDs from namecheap. // 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) values := make(url.Values)
d.setGlobalParams(&values, "namecheap.domains.getTldList") 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() reqURL.RawQuery = values.Encode()
resp, err := d.client.Get(reqURL.String()) resp, err := d.config.HTTPClient.Get(reqURL.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -208,21 +292,12 @@ func (d *DNSProvider) getTLDs() (tlds map[string]string, err error) {
return nil, err return nil, err
} }
type GetTldsResponse struct { var gtr getTldsResponse
XMLName xml.Name `xml:"ApiResponse"`
Errors []apierror `xml:"Errors>Error"`
Result []struct {
Name string `xml:",attr"`
} `xml:"CommandResponse>Tlds>Tld"`
}
var gtr GetTldsResponse
if err := xml.Unmarshal(body, &gtr); err != nil { if err := xml.Unmarshal(body, &gtr); err != nil {
return nil, err return nil, err
} }
if len(gtr.Errors) > 0 { if len(gtr.Errors) > 0 {
return nil, fmt.Errorf("Namecheap error: %s [%d]", return nil, fmt.Errorf("%s [%d]", gtr.Errors[0].Description, gtr.Errors[0].Number)
gtr.Errors[0].Description, gtr.Errors[0].Number)
} }
tlds = make(map[string]string) 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) { func (d *DNSProvider) getHosts(ch *challenge) (hosts []host, err error) {
values := make(url.Values) values := make(url.Values)
d.setGlobalParams(&values, "namecheap.domains.dns.getHosts") d.setGlobalParams(&values, "namecheap.domains.dns.getHosts")
values.Set("SLD", ch.sld) values.Set("SLD", ch.sld)
values.Set("TLD", ch.tld) 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() reqURL.RawQuery = values.Encode()
resp, err := d.client.Get(reqURL.String()) resp, err := d.config.HTTPClient.Get(reqURL.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -257,20 +336,12 @@ func (d *DNSProvider) getHosts(ch *challenge) (hosts []host, err error) {
return nil, err return nil, err
} }
type GetHostsResponse struct { var ghr getHostsResponse
XMLName xml.Name `xml:"ApiResponse"`
Status string `xml:"Status,attr"`
Errors []apierror `xml:"Errors>Error"`
Hosts []host `xml:"CommandResponse>DomainDNSGetHostsResult>host"`
}
var ghr GetHostsResponse
if err = xml.Unmarshal(body, &ghr); err != nil { if err = xml.Unmarshal(body, &ghr); err != nil {
return nil, err return nil, err
} }
if len(ghr.Errors) > 0 { if len(ghr.Errors) > 0 {
return nil, fmt.Errorf("Namecheap error: %s [%d]", return nil, fmt.Errorf("%s [%d]", ghr.Errors[0].Description, ghr.Errors[0].Number)
ghr.Errors[0].Description, ghr.Errors[0].Number)
} }
return ghr.Hosts, nil 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 { func (d *DNSProvider) setHosts(ch *challenge, hosts []host) error {
values := make(url.Values) values := make(url.Values)
d.setGlobalParams(&values, "namecheap.domains.dns.setHosts") d.setGlobalParams(&values, "namecheap.domains.dns.setHosts")
values.Set("SLD", ch.sld) values.Set("SLD", ch.sld)
values.Set("TLD", ch.tld) values.Set("TLD", ch.tld)
@ -292,7 +364,7 @@ func (d *DNSProvider) setHosts(ch *challenge, hosts []host) error {
values.Add("TTL"+ind, h.TTL) 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 { if err != nil {
return err return err
} }
@ -307,25 +379,15 @@ func (d *DNSProvider) setHosts(ch *challenge, hosts []host) error {
return err return err
} }
type SetHostsResponse struct { var shr setHostsResponse
XMLName xml.Name `xml:"ApiResponse"`
Status string `xml:"Status,attr"`
Errors []apierror `xml:"Errors>Error"`
Result struct {
IsSuccess string `xml:",attr"`
} `xml:"CommandResponse>DomainDNSSetHostsResult"`
}
var shr SetHostsResponse
if err := xml.Unmarshal(body, &shr); err != nil { if err := xml.Unmarshal(body, &shr); err != nil {
return err return err
} }
if len(shr.Errors) > 0 { if len(shr.Errors) > 0 {
return fmt.Errorf("Namecheap error: %s [%d]", return fmt.Errorf("%s [%d]", shr.Errors[0].Description, shr.Errors[0].Number)
shr.Errors[0].Description, shr.Errors[0].Number)
} }
if shr.Result.IsSuccess != "true" { if shr.Result.IsSuccess != "true" {
return fmt.Errorf("Namecheap setHosts failed") return fmt.Errorf("setHosts failed")
} }
return nil return nil
@ -339,7 +401,7 @@ func (d *DNSProvider) addChallengeRecord(ch *challenge, hosts *[]host) {
Type: "TXT", Type: "TXT",
Address: ch.keyValue, Address: ch.keyValue,
MXPref: "10", MXPref: "10",
TTL: "120", TTL: strconv.Itoa(d.config.TTL),
} }
// If there's already a TXT record with the same name, replace it. // 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 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)
}

View file

@ -7,6 +7,9 @@ import (
"net/url" "net/url"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
var ( 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) { func assertEq(t *testing.T, variable, got, want string) {
if got != want { if got != want {
t.Errorf("Expected %s to be '%s' but got '%s'", variable, want, got) 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 { func mockDNSProvider(url string) *DNSProvider {
return &DNSProvider{ config := NewDefaultConfig()
baseURL: url, config.BaseURL = url
apiUser: fakeUser, config.APIUser = fakeUser
apiKey: fakeKey, config.APIKey = fakeKey
clientIP: fakeClientIP, config.ClientIP = fakeClientIP
client: &http.Client{Timeout: 60 * time.Second}, config.HTTPClient = &http.Client{Timeout: 60 * time.Second}
}
}
func testSetHosts(tc *testcase, t *testing.T) { provider, _ := NewDNSProviderConfig(config)
mock := httptest.NewServer(http.HandlerFunc( return provider
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)
}
})
}
} }
type testcase struct { type testcase struct {
@ -279,38 +273,34 @@ type testcase struct {
var testcases = []testcase{ var testcases = []testcase{
{ {
"Test:Success:1", name: "Test:Success:1",
"test.example.com", domain: "test.example.com",
[]host{ hosts: []host{
{"A", "home", "10.0.0.1", "10", "1799"}, {Type: "A", Name: "home", Address: "10.0.0.1", MXPref: "10", TTL: "1799"},
{"A", "www", "10.0.0.2", "10", "1200"}, {Type: "A", Name: "www", Address: "10.0.0.2", MXPref: "10", TTL: "1200"},
{"AAAA", "a", "::0", "10", "1799"}, {Type: "AAAA", Name: "a", Address: "::0", MXPref: "10", TTL: "1799"},
{"CNAME", "*", "example.com.", "10", "1799"}, {Type: "CNAME", Name: "*", Address: "example.com.", MXPref: "10", TTL: "1799"},
{"MXE", "example.com", "10.0.0.5", "10", "1800"}, {Type: "MXE", Name: "example.com", Address: "10.0.0.5", MXPref: "10", TTL: "1800"},
{"URL", "xyz", "https://google.com", "10", "1799"}, {Type: "URL", Name: "xyz", Address: "https://google.com", MXPref: "10", TTL: "1799"},
}, },
"", getHostsResponse: responseGetHostsSuccess1,
responseGetHostsSuccess1, setHostsResponse: responseSetHostsSuccess1,
responseSetHostsSuccess1,
}, },
{ {
"Test:Success:2", name: "Test:Success:2",
"example.com", domain: "example.com",
[]host{ hosts: []host{
{"A", "@", "10.0.0.2", "10", "1200"}, {Type: "A", Name: "@", Address: "10.0.0.2", MXPref: "10", TTL: "1200"},
{"A", "www", "10.0.0.3", "10", "60"}, {Type: "A", Name: "www", Address: "10.0.0.3", MXPref: "10", TTL: "60"},
}, },
"", getHostsResponse: responseGetHostsSuccess2,
responseGetHostsSuccess2, setHostsResponse: responseSetHostsSuccess2,
responseSetHostsSuccess2,
}, },
{ {
"Test:Error:BadApiKey:1", name: "Test:Error:BadApiKey:1",
"test.example.com", domain: "test.example.com",
nil, errString: "API Key is invalid or API access has not been enabled [1011102]",
"Namecheap error: API Key is invalid or API access has not been enabled [1011102]", getHostsResponse: responseGetHostsErrorBadAPIKey1,
responseGetHostsErrorBadAPIKey1,
"",
}, },
} }

View file

@ -3,66 +3,115 @@
package namedotcom package namedotcom
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"os" "os"
"strings" "strings"
"time"
"github.com/namedotcom/go/namecom" "github.com/namedotcom/go/namecom"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
"github.com/xenolf/lego/platform/config/env" "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. // DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProvider struct { type DNSProvider struct {
client *namecom.NameCom client *namecom.NameCom
config *Config
} }
// NewDNSProvider returns a DNSProvider instance configured for namedotcom. // 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) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("NAMECOM_USERNAME", "NAMECOM_API_TOKEN") values, err := env.Get("NAMECOM_USERNAME", "NAMECOM_API_TOKEN")
if err != nil { if err != nil {
return nil, fmt.Errorf("Name.com: %v", err) return nil, fmt.Errorf("namedotcom: %v", err)
} }
server := os.Getenv("NAMECOM_SERVER") config := NewDefaultConfig()
return NewDNSProviderCredentials(values["NAMECOM_USERNAME"], values["NAMECOM_API_TOKEN"], server) 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for namedotcom. // to return a DNSProvider instance configured for namedotcom.
// Deprecated
func NewDNSProviderCredentials(username, apiToken, server string) (*DNSProvider, error) { func NewDNSProviderCredentials(username, apiToken, server string) (*DNSProvider, error) {
if username == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("Name.com Username is required") config.Username = username
} config.APIToken = apiToken
if apiToken == "" { config.Server = server
return nil, fmt.Errorf("Name.com API token is required")
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 config.Username == "" {
return nil, fmt.Errorf("namedotcom: username is required")
if server != "" {
client.Server = server
} }
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. // Present creates a TXT record to fulfil the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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{ request := &namecom.Record{
DomainName: domain, DomainName: domain,
Host: d.extractRecordName(fqdn, domain), Host: d.extractRecordName(fqdn, domain),
Type: "TXT", Type: "TXT",
TTL: uint32(ttl), TTL: uint32(d.config.TTL),
Answer: value, Answer: value,
} }
_, err := d.client.CreateRecord(request) _, err := d.client.CreateRecord(request)
if err != nil { if err != nil {
return fmt.Errorf("Name.com API call failed: %v", err) return fmt.Errorf("namedotcom: API call failed: %v", err)
} }
return nil return nil
@ -74,7 +123,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
records, err := d.getRecords(domain) records, err := d.getRecords(domain)
if err != nil { if err != nil {
return err return fmt.Errorf("namedotcom: %v", err)
} }
for _, rec := range records { for _, rec := range records {
@ -85,7 +134,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
} }
_, err := d.client.DeleteRecord(request) _, err := d.client.DeleteRecord(request)
if err != nil { 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 return nil
} }
func (d *DNSProvider) getRecords(domain string) ([]*namecom.Record, error) { // Timeout returns the timeout and interval to use when checking for DNS propagation.
var ( // Adjusting here to cope with spikes in propagation times.
err error func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
records []*namecom.Record return d.config.PropagationTimeout, d.config.PollingInterval
response *namecom.ListRecordsResponse }
)
func (d *DNSProvider) getRecords(domain string) ([]*namecom.Record, error) {
request := &namecom.ListRecordsRequest{ request := &namecom.ListRecordsRequest{
DomainName: domain, DomainName: domain,
Page: 1, Page: 1,
} }
var records []*namecom.Record
for request.Page > 0 { for request.Page > 0 {
response, err = d.client.ListRecords(request) response, err := d.client.ListRecords(request)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -32,7 +32,12 @@ func TestLiveNamedotcomPresent(t *testing.T) {
t.Skip("skipping live test") 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) assert.NoError(t, err)
err = provider.Present(namedotcomDomain, "", "123d==") err = provider.Present(namedotcomDomain, "", "123d==")
@ -50,7 +55,12 @@ func TestLiveNamedotcomCleanUp(t *testing.T) {
time.Sleep(time.Second * 1) 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) assert.NoError(t, err)
err = provider.CleanUp(namedotcomDomain, "", "123d==") err = provider.CleanUp(namedotcomDomain, "", "123d==")

View file

@ -6,12 +6,13 @@ import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"time"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
) )
// netcupBaseURL for reaching the jSON-based API-Endpoint of netcup // defaultBaseURL for reaching the jSON-based API-Endpoint of netcup
const netcupBaseURL = "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON" const defaultBaseURL = "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON"
// success response status // success response status
const success = "success" const success = "success"
@ -80,6 +81,7 @@ type DNSRecord struct {
Destination string `json:"destination"` Destination string `json:"destination"`
DeleteRecord bool `json:"deleterecord,omitempty"` DeleteRecord bool `json:"deleterecord,omitempty"`
State string `json:"state,omitempty"` State string `json:"state,omitempty"`
TTL int `json:"ttl,omitempty"`
} }
// ResponseMsg as specified in netcup WSDL // ResponseMsg as specified in netcup WSDL
@ -119,21 +121,20 @@ type Client struct {
customerNumber string customerNumber string
apiKey string apiKey string
apiPassword string apiPassword string
client *http.Client HTTPClient *http.Client
BaseURL string
} }
// NewClient creates a netcup DNS client // NewClient creates a netcup DNS client
func NewClient(httpClient *http.Client, customerNumber string, apiKey string, apiPassword string) *Client { func NewClient(customerNumber string, apiKey string, apiPassword string) *Client {
client := http.DefaultClient
if httpClient != nil {
client = httpClient
}
return &Client{ return &Client{
customerNumber: customerNumber, customerNumber: customerNumber,
apiKey: apiKey, apiKey: apiKey,
apiPassword: apiPassword, 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) response, err := c.sendRequest(payload)
if err != nil { 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 var r ResponseMsg
err = json.Unmarshal(response, &r) err = json.Unmarshal(response, &r)
if err != nil { 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 { 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 return r.ResponseData.APISessionID, nil
} }
@ -183,18 +184,18 @@ func (c *Client) Logout(sessionID string) error {
response, err := c.sendRequest(payload) response, err := c.sendRequest(payload)
if err != nil { 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 var r LogoutResponseMsg
err = json.Unmarshal(response, &r) err = json.Unmarshal(response, &r)
if err != nil { 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 { 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 return nil
} }
@ -216,18 +217,18 @@ func (c *Client) UpdateDNSRecord(sessionID, domainName string, record DNSRecord)
response, err := c.sendRequest(payload) response, err := c.sendRequest(payload)
if err != nil { if err != nil {
return fmt.Errorf("netcup: %v", err) return err
} }
var r ResponseMsg var r ResponseMsg
err = json.Unmarshal(response, &r) err = json.Unmarshal(response, &r)
if err != nil { if err != nil {
return fmt.Errorf("netcup: %v", err) return err
} }
if r.Status != success { if r.Status != success {
return fmt.Errorf("netcup: %s: %+v", r.ShortMessage, r) return fmt.Errorf("%s: %+v", r.ShortMessage, r)
} }
return nil return nil
} }
@ -249,18 +250,18 @@ func (c *Client) GetDNSRecords(hostname, apiSessionID string) ([]DNSRecord, erro
response, err := c.sendRequest(payload) response, err := c.sendRequest(payload)
if err != nil { if err != nil {
return nil, fmt.Errorf("netcup: %v", err) return nil, err
} }
var r ResponseMsg var r ResponseMsg
err = json.Unmarshal(response, &r) err = json.Unmarshal(response, &r)
if err != nil { if err != nil {
return nil, fmt.Errorf("netcup: %v", err) return nil, err
} }
if r.Status != success { if r.Status != success {
return nil, fmt.Errorf("netcup: %s", r.ShortMessage) return nil, fmt.Errorf("%s", r.ShortMessage)
} }
return r.ResponseData.DNSRecords, nil 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) { func (c *Client) sendRequest(payload interface{}) ([]byte, error) {
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil { 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 { if err != nil {
return nil, fmt.Errorf("netcup: %v", err) return nil, err
} }
req.Close = true req.Close = true
req.Header.Set("content-type", "application/json") req.Header.Set("content-type", "application/json")
req.Header.Set("User-Agent", acme.UserAgent) req.Header.Set("User-Agent", acme.UserAgent)
resp, err := c.client.Do(req) resp, err := c.HTTPClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("netcup: %v", err) return nil, err
} }
if resp.StatusCode > 299 { 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) body, err = ioutil.ReadAll(resp.Body)
if err != nil { 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() defer resp.Body.Close()
@ -310,11 +311,11 @@ func GetDNSRecordIdx(records []DNSRecord, record DNSRecord) (int, error) {
return index, nil 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 // 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{ return DNSRecord{
ID: 0, ID: 0,
Hostname: hostname, Hostname: hostname,
@ -323,5 +324,6 @@ func CreateTxtRecord(hostname, value string) DNSRecord {
Destination: value, Destination: value,
DeleteRecord: false, DeleteRecord: false,
State: "", State: "",
TTL: ttl,
} }
} }

View file

@ -2,10 +2,8 @@ package netcup
import ( import (
"fmt" "fmt"
"net/http"
"strings" "strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
@ -17,10 +15,7 @@ func TestClientAuth(t *testing.T) {
} }
// Setup // Setup
httpClient := &http.Client{ client := NewClient(testCustomerNumber, testAPIKey, testAPIPassword)
Timeout: 10 * time.Second,
}
client := NewClient(httpClient, testCustomerNumber, testAPIKey, testAPIPassword)
for i := 1; i < 4; i++ { for i := 1; i < 4; i++ {
i := i i := i
@ -42,10 +37,7 @@ func TestClientGetDnsRecords(t *testing.T) {
t.Skip("skipping live test") t.Skip("skipping live test")
} }
httpClient := &http.Client{ client := NewClient(testCustomerNumber, testAPIKey, testAPIPassword)
Timeout: 10 * time.Second,
}
client := NewClient(httpClient, testCustomerNumber, testAPIKey, testAPIPassword)
// Setup // Setup
sessionID, err := client.Login() sessionID, err := client.Login()
@ -73,10 +65,7 @@ func TestClientUpdateDnsRecord(t *testing.T) {
} }
// Setup // Setup
httpClient := &http.Client{ client := NewClient(testCustomerNumber, testAPIKey, testAPIPassword)
Timeout: 10 * time.Second,
}
client := NewClient(httpClient, testCustomerNumber, testAPIKey, testAPIPassword)
sessionID, err := client.Login() sessionID, err := client.Login()
assert.NoError(t, err) assert.NoError(t, err)
@ -88,7 +77,7 @@ func TestClientUpdateDnsRecord(t *testing.T) {
hostname := strings.Replace(fqdn, "."+zone, "", 1) hostname := strings.Replace(fqdn, "."+zone, "", 1)
record := CreateTxtRecord(hostname, "asdf5678") record := CreateTxtRecord(hostname, "asdf5678", 120)
// test // test
zone = acme.UnFqdn(zone) zone = acme.UnFqdn(zone)

View file

@ -2,6 +2,7 @@
package netcup package netcup
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@ -11,37 +12,78 @@ import (
"github.com/xenolf/lego/platform/config/env" "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 // DNSProvider is an implementation of the acme.ChallengeProvider interface
type DNSProvider struct { type DNSProvider struct {
client *Client client *Client
config *Config
} }
// NewDNSProvider returns a DNSProvider instance configured for netcup. // NewDNSProvider returns a DNSProvider instance configured for netcup.
// Credentials must be passed in the environment variables: NETCUP_CUSTOMER_NUMBER, // Credentials must be passed in the environment variables:
// NETCUP_API_KEY, NETCUP_API_PASSWORD // NETCUP_CUSTOMER_NUMBER, NETCUP_API_KEY, NETCUP_API_PASSWORD
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("NETCUP_CUSTOMER_NUMBER", "NETCUP_API_KEY", "NETCUP_API_PASSWORD") values, err := env.Get("NETCUP_CUSTOMER_NUMBER", "NETCUP_API_KEY", "NETCUP_API_PASSWORD")
if err != nil { if err != nil {
return nil, fmt.Errorf("netcup: %v", err) 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for netcup. // to return a DNSProvider instance configured for netcup.
// Deprecated
func NewDNSProviderCredentials(customer, key, password string) (*DNSProvider, error) { 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") return nil, fmt.Errorf("netcup: netcup credentials missing")
} }
httpClient := &http.Client{ client := NewClient(config.Customer, config.Key, config.Password)
Timeout: 10 * time.Second, client.HTTPClient = config.HTTPClient
}
return &DNSProvider{ return &DNSProvider{client: client, config: config}, nil
client: NewClient(httpClient, customer, key, password),
}, nil
} }
// Present creates a TXT record to fulfill the dns-01 challenge // 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() sessionID, err := d.client.Login()
if err != nil { if err != nil {
return err return fmt.Errorf("netcup: %v", err)
} }
hostname := strings.Replace(fqdn, "."+zone, "", 1) 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) err = d.client.UpdateDNSRecord(sessionID, acme.UnFqdn(zone), record)
if err != nil { if err != nil {
if errLogout := d.client.Logout(sessionID); errLogout != 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 // 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) zone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers)
if err != nil { 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() sessionID, err := d.client.Login()
if err != nil { if err != nil {
return err return fmt.Errorf("netcup: %v", err)
} }
hostname := strings.Replace(fqdn, "."+zone, "", 1) 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) records, err := d.client.GetDNSRecords(zone, sessionID)
if err != nil { 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) idx, err := GetDNSRecordIdx(records, record)
if err != nil { if err != nil {
return err return fmt.Errorf("netcup: %v", err)
} }
records[idx].DeleteRecord = true 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]) err = d.client.UpdateDNSRecord(sessionID, zone, records[idx])
if err != nil { if err != nil {
if errLogout := d.client.Logout(sessionID); errLogout != 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
} }

View file

@ -15,7 +15,7 @@ import (
) )
const ( const (
defaultEndpoint = "https://dns.api.cloud.nifty.com" defaultBaseURL = "https://dns.api.cloud.nifty.com"
apiVersion = "2012-12-12N2013-12-16" apiVersion = "2012-12-12N2013-12-16"
xmlNs = "https://route53.amazonaws.com/doc/2012-12-12/" xmlNs = "https://route53.amazonaws.com/doc/2012-12-12/"
) )
@ -88,17 +88,13 @@ type ChangeInfo struct {
SubmittedAt string `xml:"SubmittedAt"` SubmittedAt string `xml:"SubmittedAt"`
} }
func newClient(httpClient *http.Client, accessKey string, secretKey string, endpoint string) *Client { // NewClient Creates a new client of NIFCLOUD DNS
client := http.DefaultClient func NewClient(accessKey string, secretKey string) *Client {
if httpClient != nil {
client = httpClient
}
return &Client{ return &Client{
accessKey: accessKey, accessKey: accessKey,
secretKey: secretKey, secretKey: secretKey,
endpoint: endpoint, BaseURL: defaultBaseURL,
client: client, HTTPClient: &http.Client{},
} }
} }
@ -106,13 +102,13 @@ func newClient(httpClient *http.Client, accessKey string, secretKey string, endp
type Client struct { type Client struct {
accessKey string accessKey string
secretKey string secretKey string
endpoint string BaseURL string
client *http.Client HTTPClient *http.Client
} }
// ChangeResourceRecordSets Call ChangeResourceRecordSets API and return response. // ChangeResourceRecordSets Call ChangeResourceRecordSets API and return response.
func (c *Client) ChangeResourceRecordSets(hostedZoneID string, input ChangeResourceRecordSetsRequest) (*ChangeResourceRecordSetsResponse, error) { 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 := &bytes.Buffer{}
body.Write([]byte(xml.Header)) 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) 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 { if err != nil {
return nil, err return nil, err
} }
@ -164,7 +160,7 @@ func (c *Client) ChangeResourceRecordSets(hostedZoneID string, input ChangeResou
// GetChange Call GetChange API and return response. // GetChange Call GetChange API and return response.
func (c *Client) GetChange(statusID string) (*GetChangeResponse, error) { 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) req, err := http.NewRequest(http.MethodGet, requestURL, nil)
if err != 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) 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 { if err != nil {
return nil, err return nil, err
} }

View file

@ -31,7 +31,8 @@ func TestChangeResourceRecordSets(t *testing.T) {
server := runTestServer(responseBody, http.StatusOK) server := runTestServer(responseBody, http.StatusOK)
defer server.Close() defer server.Close()
client := newClient(nil, "", "", server.URL) client := NewClient("", "")
client.BaseURL = server.URL
res, err := client.ChangeResourceRecordSets("example.com", ChangeResourceRecordSetsRequest{}) res, err := client.ChangeResourceRecordSets("example.com", ChangeResourceRecordSetsRequest{})
require.NoError(t, err) require.NoError(t, err)
@ -82,7 +83,8 @@ func TestChangeResourceRecordSetsErrors(t *testing.T) {
server := runTestServer(test.responseBody, test.statusCode) server := runTestServer(test.responseBody, test.statusCode)
defer server.Close() defer server.Close()
client := newClient(nil, "", "", server.URL) client := NewClient("", "")
client.BaseURL = server.URL
res, err := client.ChangeResourceRecordSets("example.com", ChangeResourceRecordSetsRequest{}) res, err := client.ChangeResourceRecordSets("example.com", ChangeResourceRecordSetsRequest{})
assert.Nil(t, res) assert.Nil(t, res)
@ -105,7 +107,8 @@ func TestGetChange(t *testing.T) {
server := runTestServer(responseBody, http.StatusOK) server := runTestServer(responseBody, http.StatusOK)
defer server.Close() defer server.Close()
client := newClient(nil, "", "", server.URL) client := NewClient("", "")
client.BaseURL = server.URL
res, err := client.GetChange("12345") res, err := client.GetChange("12345")
require.NoError(t, err) require.NoError(t, err)
@ -156,7 +159,8 @@ func TestGetChangeErrors(t *testing.T) {
server := runTestServer(test.responseBody, test.statusCode) server := runTestServer(test.responseBody, test.statusCode)
defer server.Close() defer server.Close()
client := newClient(nil, "", "", server.URL) client := NewClient("", "")
client.BaseURL = server.URL
res, err := client.GetChange("12345") res, err := client.GetChange("12345")
assert.Nil(t, res) assert.Nil(t, res)

View file

@ -3,6 +3,7 @@
package nifcloud package nifcloud
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@ -12,49 +13,110 @@ import (
"github.com/xenolf/lego/platform/config/env" "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 // DNSProvider implements the acme.ChallengeProvider interface
type DNSProvider struct { type DNSProvider struct {
client *Client client *Client
config *Config
} }
// NewDNSProvider returns a DNSProvider instance configured for the NIFCLOUD DNS service. // 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) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("NIFCLOUD_ACCESS_KEY_ID", "NIFCLOUD_SECRET_ACCESS_KEY") values, err := env.Get("NIFCLOUD_ACCESS_KEY_ID", "NIFCLOUD_SECRET_ACCESS_KEY")
if err != nil { if err != nil {
return nil, fmt.Errorf("NIFCLOUD: %v", err) return nil, fmt.Errorf("nifcloud: %v", err)
} }
endpoint := os.Getenv("NIFCLOUD_DNS_ENDPOINT") config := NewDefaultConfig()
if endpoint == "" { config.BaseURL = os.Getenv("NIFCLOUD_DNS_ENDPOINT")
endpoint = defaultEndpoint config.AccessKey = values["NIFCLOUD_ACCESS_KEY_ID"]
} config.SecretKey = values["NIFCLOUD_SECRET_ACCESS_KEY"]
httpClient := &http.Client{Timeout: 30 * time.Second} return NewDNSProviderConfig(config)
return NewDNSProviderCredentials(httpClient, endpoint, values["NIFCLOUD_ACCESS_KEY_ID"], values["NIFCLOUD_SECRET_ACCESS_KEY"])
} }
// NewDNSProviderCredentials uses the supplied credentials to return a // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for NIFCLOUD. // to return a DNSProvider instance configured for NIFCLOUD.
// Deprecated
func NewDNSProviderCredentials(httpClient *http.Client, endpoint, accessKey, secretKey string) (*DNSProvider, error) { 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{ return NewDNSProviderConfig(config)
client: client, }
}, nil
// 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 // Present creates a TXT record using the specified parameters
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
return d.changeRecord("CREATE", fqdn, value, domain, ttl)
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 // CleanUp removes the TXT record matching the specified parameters
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
return d.changeRecord("DELETE", fqdn, value, domain, ttl)
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 { func (d *DNSProvider) changeRecord(action, fqdn, value, domain string, ttl int) error {

View file

@ -3,6 +3,7 @@
package ns1 package ns1
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@ -14,9 +15,31 @@ import (
"gopkg.in/ns1/ns1-go.v2/rest/model/dns" "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. // DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProvider struct { type DNSProvider struct {
client *rest.Client client *rest.Client
config *Config
} }
// NewDNSProvider returns a DNSProvider instance configured for NS1. // NewDNSProvider returns a DNSProvider instance configured for NS1.
@ -24,38 +47,53 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("NS1_API_KEY") values, err := env.Get("NS1_API_KEY")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for NS1. // to return a DNSProvider instance configured for NS1.
// Deprecated
func NewDNSProviderCredentials(key string) (*DNSProvider, error) { func NewDNSProviderCredentials(key string) (*DNSProvider, error) {
if key == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("NS1 credentials missing") 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} if config.APIKey == "" {
client := rest.NewClient(httpClient, rest.SetAPIKey(key)) 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. // Present creates a TXT record to fulfil the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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) zone, err := d.getHostedZone(domain)
if err != nil { 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) _, err = d.client.Records.Create(record)
if err != nil && err != rest.ErrRecordExists { if err != nil && err != rest.ErrRecordExists {
return err return fmt.Errorf("ns1: %v", err)
} }
return nil return nil
@ -67,23 +105,29 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
zone, err := d.getHostedZone(domain) zone, err := d.getHostedZone(domain)
if err != nil { if err != nil {
return err return fmt.Errorf("ns1: %v", err)
} }
name := acme.UnFqdn(fqdn) name := acme.UnFqdn(fqdn)
_, err = d.client.Records.Delete(zone.Zone, name, "TXT") _, 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) { func (d *DNSProvider) getHostedZone(domain string) (*dns.Zone, error) {
authZone, err := getAuthZone(domain) authZone, err := getAuthZone(domain)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("ns1: %v", err)
} }
zone, _, err := d.client.Zones.Get(authZone) zone, _, err := d.client.Zones.Get(authZone)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("ns1: %v", err)
} }
return zone, nil return zone, nil

View file

@ -30,7 +30,10 @@ func TestNewDNSProviderValid(t *testing.T) {
defer restoreEnv() defer restoreEnv()
os.Setenv("NS1_API_KEY", "") os.Setenv("NS1_API_KEY", "")
_, err := NewDNSProviderCredentials("123") config := NewDefaultConfig()
config.APIKey = "123"
_, err := NewDNSProviderConfig(config)
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -39,7 +42,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("NS1_API_KEY", "") os.Setenv("NS1_API_KEY", "")
_, err := NewDNSProvider() _, 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) { func TestLivePresent(t *testing.T) {
@ -47,7 +50,10 @@ func TestLivePresent(t *testing.T) {
t.Skip("skipping live test") t.Skip("skipping live test")
} }
provider, err := NewDNSProviderCredentials(apiKey) config := NewDefaultConfig()
config.APIKey = apiKey
provider, err := NewDNSProviderConfig(config)
assert.NoError(t, err) assert.NoError(t, err)
err = provider.Present(domain, "", "123d==") err = provider.Present(domain, "", "123d==")
@ -61,7 +67,10 @@ func TestLiveCleanUp(t *testing.T) {
time.Sleep(time.Second * 1) time.Sleep(time.Second * 1)
provider, err := NewDNSProviderCredentials(apiKey) config := NewDefaultConfig()
config.APIKey = apiKey
provider, err := NewDNSProviderConfig(config)
assert.NoError(t, err) assert.NoError(t, err)
err = provider.CleanUp(domain, "", "123d==") err = provider.CleanUp(domain, "", "123d==")

View file

@ -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"`
}

View file

@ -5,26 +5,69 @@ package otc
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"net"
"net/http" "net/http"
"os"
"time" "time"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
"github.com/xenolf/lego/platform/config/env" "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 // DNSProvider is an implementation of the acme.ChallengeProvider interface that uses
// OTC's Managed DNS API to manage TXT records for a domain. // OTC's Managed DNS API to manage TXT records for a domain.
type DNSProvider struct { type DNSProvider struct {
identityEndpoint string config *Config
otcBaseURL string baseURL string
domainName string
projectName string
userName string
password string
token string token string
} }
@ -34,41 +77,129 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("OTC_DOMAIN_NAME", "OTC_USER_NAME", "OTC_PASSWORD", "OTC_PROJECT_NAME") values, err := env.Get("OTC_DOMAIN_NAME", "OTC_USER_NAME", "OTC_PASSWORD", "OTC_PROJECT_NAME")
if err != nil { if err != nil {
return nil, fmt.Errorf("OTC: %v", err) return nil, fmt.Errorf("otc: %v", err)
} }
return NewDNSProviderCredentials( config := NewDefaultConfig()
values["OTC_DOMAIN_NAME"], config.DomainName = values["OTC_DOMAIN_NAME"]
values["OTC_USER_NAME"], config.UserName = values["OTC_USER_NAME"]
values["OTC_PASSWORD"], config.Password = values["OTC_PASSWORD"]
values["OTC_PROJECT_NAME"], config.ProjectName = values["OTC_PROJECT_NAME"]
os.Getenv("OTC_IDENTITY_ENDPOINT"),
) return NewDNSProviderConfig(config)
} }
// NewDNSProviderCredentials uses the supplied credentials to return a // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for OTC DNS. // to return a DNSProvider instance configured for OTC DNS.
// Deprecated
func NewDNSProviderCredentials(domainName, userName, password, projectName, identityEndpoint string) (*DNSProvider, error) { func NewDNSProviderCredentials(domainName, userName, password, projectName, identityEndpoint string) (*DNSProvider, error) {
if domainName == "" || userName == "" || password == "" || projectName == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("OTC credentials missing") config.IdentityEndpoint = identityEndpoint
} config.DomainName = domainName
config.UserName = userName
config.Password = password
config.ProjectName = projectName
if identityEndpoint == "" { return NewDNSProviderConfig(config)
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
} }
// SendRequest send request // NewDNSProviderConfig return a DNSProvider instance configured for OTC DNS.
func (d *DNSProvider) SendRequest(method, resource string, payload interface{}) (io.Reader, error) { func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
url := fmt.Sprintf("%s/%s", d.otcBaseURL, resource) 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) body, err := json.Marshal(payload)
if err != nil { if err != nil {
@ -84,15 +215,7 @@ func (d *DNSProvider) SendRequest(method, resource string, payload interface{})
req.Header.Set("X-Auth-Token", d.token) req.Header.Set("X-Auth-Token", d.token)
} }
// Workaround for keep alive bug in otc api resp, err := d.config.HTTPClient.Do(req)
tr := http.DefaultTransport.(*http.Transport)
tr.DisableKeepAlives = true
client := &http.Client{
Timeout: 10 * time.Second,
Transport: tr,
}
resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -111,42 +234,11 @@ func (d *DNSProvider) SendRequest(method, resource string, payload interface{})
} }
func (d *DNSProvider) loginRequest() error { 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{ userResp := userResponse{
Name: d.userName, Name: d.config.UserName,
Password: d.password, Password: d.config.Password,
Domain: nameResponse{ Domain: nameResponse{
Name: d.domainName, Name: d.config.DomainName,
}, },
} }
@ -160,7 +252,7 @@ func (d *DNSProvider) loginRequest() error {
}, },
Scope: scopeResponse{ Scope: scopeResponse{
Project: nameResponse{ Project: nameResponse{
Name: d.projectName, Name: d.config.ProjectName,
}, },
}, },
}, },
@ -170,13 +262,14 @@ func (d *DNSProvider) loginRequest() error {
if err != nil { if err != nil {
return err 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 { if err != nil {
return err return err
} }
req.Header.Set("Content-Type", "application/json") 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) resp, err := client.Do(req)
if err != nil { if err != nil {
return err return err
@ -193,16 +286,6 @@ func (d *DNSProvider) loginRequest() error {
return fmt.Errorf("unable to get auth token") 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 var endpointResp endpointResponse
err = json.NewDecoder(resp.Body).Decode(&endpointResp) err = json.NewDecoder(resp.Body).Decode(&endpointResp)
@ -213,13 +296,13 @@ func (d *DNSProvider) loginRequest() error {
for _, v := range endpointResp.Token.Catalog { for _, v := range endpointResp.Token.Catalog {
if v.Type == "dns" { if v.Type == "dns" {
for _, endpoint := range v.Endpoints { for _, endpoint := range v.Endpoints {
d.otcBaseURL = fmt.Sprintf("%s/v2", endpoint.URL) d.baseURL = fmt.Sprintf("%s/v2", endpoint.URL)
continue continue
} }
} }
} }
if d.otcBaseURL == "" { if d.baseURL == "" {
return fmt.Errorf("unable to get dns endpoint") 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) { 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) 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 { if err != nil {
return "", err return "", err
} }
@ -269,16 +344,8 @@ func (d *DNSProvider) getZoneID(zone string) (string, error) {
} }
func (d *DNSProvider) getRecordSetID(zoneID string, fqdn 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) 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 { if err != nil {
return "", err return "", err
} }
@ -307,77 +374,6 @@ func (d *DNSProvider) getRecordSetID(zoneID string, fqdn string) (string, error)
func (d *DNSProvider) deleteRecordSet(zoneID, recordID string) error { func (d *DNSProvider) deleteRecordSet(zoneID, recordID string) error {
resource := fmt.Sprintf("zones/%s/recordsets/%s", zoneID, recordID) 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 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)
}

View file

@ -31,7 +31,15 @@ func TestOTCDNSTestSuite(t *testing.T) {
func (s *OTCDNSTestSuite) createDNSProvider() (*DNSProvider, error) { func (s *OTCDNSTestSuite) createDNSProvider() (*DNSProvider, error) {
url := fmt.Sprintf("%s/v3/auth/token", s.Mock.Server.URL) 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() { func (s *OTCDNSTestSuite) TestOTCDNSLoginEnv() {
@ -45,24 +53,24 @@ func (s *OTCDNSTestSuite) TestOTCDNSLoginEnv() {
provider, err := NewDNSProvider() provider, err := NewDNSProvider()
assert.Nil(s.T(), err) assert.Nil(s.T(), err)
assert.Equal(s.T(), provider.domainName, "unittest1") assert.Equal(s.T(), provider.config.DomainName, "unittest1")
assert.Equal(s.T(), provider.userName, "unittest2") assert.Equal(s.T(), provider.config.UserName, "unittest2")
assert.Equal(s.T(), provider.password, "unittest3") assert.Equal(s.T(), provider.config.Password, "unittest3")
assert.Equal(s.T(), provider.projectName, "unittest4") assert.Equal(s.T(), provider.config.ProjectName, "unittest4")
assert.Equal(s.T(), provider.identityEndpoint, "unittest5") assert.Equal(s.T(), provider.config.IdentityEndpoint, "unittest5")
os.Setenv("OTC_IDENTITY_ENDPOINT", "") os.Setenv("OTC_IDENTITY_ENDPOINT", "")
provider, err = NewDNSProvider() provider, err = NewDNSProvider()
assert.Nil(s.T(), err) 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() { func (s *OTCDNSTestSuite) TestOTCDNSLoginEnvEmpty() {
defer os.Clearenv() defer os.Clearenv()
_, err := NewDNSProvider() _, 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() { func (s *OTCDNSTestSuite) TestOTCDNSLogin() {
@ -71,7 +79,7 @@ func (s *OTCDNSTestSuite) TestOTCDNSLogin() {
assert.Nil(s.T(), err) assert.Nil(s.T(), err)
err = otcProvider.loginRequest() err = otcProvider.loginRequest()
assert.Nil(s.T(), err) 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) assert.Equal(s.T(), fakeOTCToken, otcProvider.token)
} }

View file

@ -3,9 +3,12 @@
package ovh package ovh
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"strings" "strings"
"sync" "sync"
"time"
"github.com/ovh/go-ovh/ovh" "github.com/ovh/go-ovh/ovh"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
@ -15,9 +18,34 @@ import (
// OVH API reference: https://eu.api.ovh.com/ // OVH API reference: https://eu.api.ovh.com/
// Create a Token: https://eu.api.ovh.com/createToken/ // 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 // DNSProvider is an implementation of the acme.ChallengeProvider interface
// that uses OVH's REST API to manage TXT records for a domain. // that uses OVH's REST API to manage TXT records for a domain.
type DNSProvider struct { type DNSProvider struct {
config *Config
client *ovh.Client client *ovh.Client
recordIDs map[string]int recordIDs map[string]int
recordIDsMu sync.Mutex recordIDsMu sync.Mutex
@ -32,69 +60,88 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("OVH_ENDPOINT", "OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY") values, err := env.Get("OVH_ENDPOINT", "OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY")
if err != nil { if err != nil {
return nil, fmt.Errorf("OVH: %v", err) return nil, fmt.Errorf("ovh: %v", err)
} }
return NewDNSProviderCredentials( config := NewDefaultConfig()
values["OVH_ENDPOINT"], config.APIEndpoint = values["OVH_ENDPOINT"]
values["OVH_APPLICATION_KEY"], config.ApplicationKey = values["OVH_APPLICATION_KEY"]
values["OVH_APPLICATION_SECRET"], config.ApplicationSecret = values["OVH_APPLICATION_SECRET"]
values["OVH_CONSUMER_KEY"], config.ConsumerKey = values["OVH_CONSUMER_KEY"]
)
return NewDNSProviderConfig(config)
} }
// NewDNSProviderCredentials uses the supplied credentials to return a // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for OVH. // to return a DNSProvider instance configured for OVH.
// Deprecated
func NewDNSProviderCredentials(apiEndpoint, applicationKey, applicationSecret, consumerKey string) (*DNSProvider, error) { func NewDNSProviderCredentials(apiEndpoint, applicationKey, applicationSecret, consumerKey string) (*DNSProvider, error) {
if apiEndpoint == "" || applicationKey == "" || applicationSecret == "" || consumerKey == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("OVH credentials missing") 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( if config.APIEndpoint == "" || config.ApplicationKey == "" || config.ApplicationSecret == "" || config.ConsumerKey == "" {
apiEndpoint, return nil, fmt.Errorf("ovh: credentials missing")
applicationKey, }
applicationSecret,
consumerKey, client, err := ovh.NewClient(
config.APIEndpoint,
config.ApplicationKey,
config.ApplicationSecret,
config.ConsumerKey,
) )
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("ovh: %v", err)
} }
client.Client = config.HTTPClient
return &DNSProvider{ return &DNSProvider{
client: ovhClient, config: config,
client: client,
recordIDs: make(map[string]int), recordIDs: make(map[string]int),
}, nil }, nil
} }
// Present creates a TXT record to fulfil the dns-01 challenge. // Present creates a TXT record to fulfil the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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 // Parse domain name
authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers)
if err != nil { 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) authZone = acme.UnFqdn(authZone)
subDomain := d.extractRecordName(fqdn, authZone) subDomain := d.extractRecordName(fqdn, authZone)
reqURL := fmt.Sprintf("/domain/zone/%s/record", 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 var respData txtRecordResponse
// Create TXT record // Create TXT record
err = d.client.Post(reqURL, reqData, &respData) err = d.client.Post(reqURL, reqData, &respData)
if err != nil { 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 // Apply the change
reqURL = fmt.Sprintf("/domain/zone/%s/refresh", authZone) reqURL = fmt.Sprintf("/domain/zone/%s/refresh", authZone)
err = d.client.Post(reqURL, nil, nil) err = d.client.Post(reqURL, nil, nil)
if err != 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() d.recordIDsMu.Lock()
@ -113,12 +160,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
recordID, ok := d.recordIDs[fqdn] recordID, ok := d.recordIDs[fqdn]
d.recordIDsMu.Unlock() d.recordIDsMu.Unlock()
if !ok { 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) authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers)
if err != nil { 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) authZone = acme.UnFqdn(authZone)
@ -127,7 +174,7 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
err = d.client.Delete(reqURL, nil) err = d.client.Delete(reqURL, nil)
if err != 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 // Delete record ID from map
@ -138,6 +185,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
return nil 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 { func (d *DNSProvider) extractRecordName(fqdn, domain string) string {
name := acme.UnFqdn(fqdn) name := acme.UnFqdn(fqdn)
if idx := strings.Index(name, "."+domain); idx != -1 { if idx := strings.Index(name, "."+domain); idx != -1 {

View file

@ -59,7 +59,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
"OVH_APPLICATION_SECRET": "5678", "OVH_APPLICATION_SECRET": "5678",
"OVH_CONSUMER_KEY": "abcde", "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", desc: "missing OVH_APPLICATION_KEY",
@ -69,7 +69,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
"OVH_APPLICATION_SECRET": "5678", "OVH_APPLICATION_SECRET": "5678",
"OVH_CONSUMER_KEY": "abcde", "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", desc: "missing OVH_APPLICATION_SECRET",
@ -79,7 +79,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
"OVH_APPLICATION_SECRET": "", "OVH_APPLICATION_SECRET": "",
"OVH_CONSUMER_KEY": "abcde", "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", desc: "missing OVH_CONSUMER_KEY",
@ -89,7 +89,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
"OVH_APPLICATION_SECRET": "5678", "OVH_APPLICATION_SECRET": "5678",
"OVH_CONSUMER_KEY": "", "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", desc: "all missing",
@ -99,7 +99,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
"OVH_APPLICATION_SECRET": "", "OVH_APPLICATION_SECRET": "",
"OVH_CONSUMER_KEY": "", "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",
}, },
} }

View file

@ -5,6 +5,7 @@ package pdns
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -18,12 +19,32 @@ import (
"github.com/xenolf/lego/platform/config/env" "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 // DNSProvider is an implementation of the acme.ChallengeProvider interface
type DNSProvider struct { type DNSProvider struct {
apiKey string
host *url.URL
apiVersion int apiVersion int
client *http.Client config *Config
} }
// NewDNSProvider returns a DNSProvider instance configured for pdns. // NewDNSProvider returns a DNSProvider instance configured for pdns.
@ -32,37 +53,51 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("PDNS_API_KEY", "PDNS_API_URL") values, err := env.Get("PDNS_API_KEY", "PDNS_API_URL")
if err != nil { 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"]) hostURL, err := url.Parse(values["PDNS_API_URL"])
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for pdns. // to return a DNSProvider instance configured for pdns.
// Deprecated
func NewDNSProviderCredentials(host *url.URL, key string) (*DNSProvider, error) { func NewDNSProviderCredentials(host *url.URL, key string) (*DNSProvider, error) {
if key == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("PDNS API key missing") 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 == "" { if config.APIKey == "" {
return nil, fmt.Errorf("PDNS API URL missing") return nil, fmt.Errorf("pdns: API key missing")
} }
d := &DNSProvider{ if config.Host == nil || config.Host.Host == "" {
host: host, return nil, fmt.Errorf("pdns: API URL missing")
apiKey: key,
client: &http.Client{Timeout: 30 * time.Second},
} }
d := &DNSProvider{config: config}
apiVersion, err := d.getAPIVersion() apiVersion, err := d.getAPIVersion()
if err != nil { 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 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 // Timeout returns the timeout and interval to use when checking for DNS
// propagation. Adjusting here to cope with spikes in propagation times. // propagation. Adjusting here to cope with spikes in propagation times.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { 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 // 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) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
zone, err := d.getHostedZone(fqdn) zone, err := d.getHostedZone(fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("pdns: %v", err)
} }
name := fqdn name := fqdn
@ -97,7 +132,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
// pre-v1 API // pre-v1 API
Type: "TXT", Type: "TXT",
Name: name, Name: name,
TTL: 120, TTL: d.config.TTL,
} }
rrsets := rrSets{ rrsets := rrSets{
@ -107,7 +142,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
ChangeType: "REPLACE", ChangeType: "REPLACE",
Type: "TXT", Type: "TXT",
Kind: "Master", Kind: "Master",
TTL: 120, TTL: d.config.TTL,
Records: []pdnsRecord{rec}, Records: []pdnsRecord{rec},
}, },
}, },
@ -115,11 +150,14 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
body, err := json.Marshal(rrsets) body, err := json.Marshal(rrsets)
if err != nil { if err != nil {
return err return fmt.Errorf("pdns: %v", err)
} }
_, err = d.makeRequest(http.MethodPatch, zone.URL, bytes.NewReader(body)) _, 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 // 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) zone, err := d.getHostedZone(fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("pdns: %v", err)
} }
set, err := d.findTxtRecord(fqdn) set, err := d.findTxtRecord(fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("pdns: %v", err)
} }
rrsets := rrSets{ rrsets := rrSets{
@ -147,11 +185,14 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
} }
body, err := json.Marshal(rrsets) body, err := json.Marshal(rrsets)
if err != nil { if err != nil {
return err return fmt.Errorf("pdns: %v", err)
} }
_, err = d.makeRequest(http.MethodPatch, zone.URL, bytes.NewReader(body)) _, 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) { func (d *DNSProvider) getHostedZone(fqdn string) (*hostedZone, error) {
@ -161,8 +202,8 @@ func (d *DNSProvider) getHostedZone(fqdn string) (*hostedZone, error) {
return nil, err return nil, err
} }
url := "/servers/localhost/zones" u := "/servers/localhost/zones"
result, err := d.makeRequest(http.MethodGet, url, nil) result, err := d.makeRequest(http.MethodGet, u, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -173,14 +214,14 @@ func (d *DNSProvider) getHostedZone(fqdn string) (*hostedZone, error) {
return nil, err return nil, err
} }
url = "" u = ""
for _, zone := range zones { for _, zone := range zones {
if acme.UnFqdn(zone.Name) == acme.UnFqdn(authZone) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -259,8 +300,8 @@ func (d *DNSProvider) makeRequest(method, uri string, body io.Reader) (json.RawM
} }
var path = "" var path = ""
if d.host.Path != "/" { if d.config.Host.Path != "/" {
path = d.host.Path path = d.config.Host.Path
} }
if !strings.HasPrefix(uri, "/") { 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 uri = "/api/v" + strconv.Itoa(d.apiVersion) + uri
} }
url := d.host.Scheme + "://" + d.host.Host + path + uri u := d.config.Host.Scheme + "://" + d.config.Host.Host + path + uri
req, err := http.NewRequest(method, url, body) req, err := http.NewRequest(method, u, body)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, fmt.Errorf("error talking to PDNS API -> %v", err) 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusUnprocessableEntity && (resp.StatusCode < 200 || resp.StatusCode >= 300) { 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 var msg json.RawMessage

View file

@ -37,7 +37,12 @@ func TestNewDNSProviderValid(t *testing.T) {
os.Setenv("PDNS_API_KEY", "") os.Setenv("PDNS_API_KEY", "")
tmpURL, _ := url.Parse("http://localhost:8081") 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) assert.NoError(t, err)
} }
@ -56,7 +61,7 @@ func TestNewDNSProviderMissingHostErr(t *testing.T) {
os.Setenv("PDNS_API_KEY", "123") os.Setenv("PDNS_API_KEY", "123")
_, err := NewDNSProvider() _, 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) { func TestNewDNSProviderMissingKeyErr(t *testing.T) {
@ -65,7 +70,7 @@ func TestNewDNSProviderMissingKeyErr(t *testing.T) {
os.Setenv("PDNS_API_KEY", "") os.Setenv("PDNS_API_KEY", "")
_, err := NewDNSProvider() _, 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) { func TestPdnsPresentAndCleanup(t *testing.T) {
@ -73,7 +78,11 @@ func TestPdnsPresentAndCleanup(t *testing.T) {
t.Skip("skipping live test") 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) assert.NoError(t, err)
err = provider.Present(pdnsDomain, "", "123d==") err = provider.Present(pdnsDomain, "", "123d==")

View file

@ -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"`
}

View file

@ -5,6 +5,7 @@ package rackspace
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -14,42 +15,85 @@ import (
"github.com/xenolf/lego/platform/config/env" "github.com/xenolf/lego/platform/config/env"
) )
// rackspaceAPIURL represents the Identity API endpoint to call // defaultBaseURL represents the Identity API endpoint to call
var rackspaceAPIURL = "https://identity.api.rackspacecloud.com/v2.0/tokens" 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 // DNSProvider is an implementation of the acme.ChallengeProvider interface
// used to store the reusable token and DNS API endpoint // used to store the reusable token and DNS API endpoint
type DNSProvider struct { type DNSProvider struct {
config *Config
token string token string
cloudDNSEndpoint string cloudDNSEndpoint string
client *http.Client
} }
// NewDNSProvider returns a DNSProvider instance configured for Rackspace. // NewDNSProvider returns a DNSProvider instance configured for Rackspace.
// Credentials must be passed in the environment variables: RACKSPACE_USER // Credentials must be passed in the environment variables:
// and RACKSPACE_API_KEY. // RACKSPACE_USER and RACKSPACE_API_KEY.
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("RACKSPACE_USER", "RACKSPACE_API_KEY") values, err := env.Get("RACKSPACE_USER", "RACKSPACE_API_KEY")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for Rackspace. It authenticates against // to return a DNSProvider instance configured for Rackspace.
// the API, also grabbing the DNS Endpoint. // It authenticates against the API, also grabbing the DNS Endpoint.
// Deprecated
func NewDNSProviderCredentials(user, key string) (*DNSProvider, error) { func NewDNSProviderCredentials(user, key string) (*DNSProvider, error) {
if user == "" || key == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("Rackspace credentials missing") 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{ authData := AuthData{
Auth: Auth{ Auth: Auth{
APIKeyCredentials: APIKeyCredentials{ APIKeyCredentials: APIKeyCredentials{
Username: user, Username: config.APIUser,
APIKey: key, APIKey: config.APIKey,
}, },
}, },
} }
@ -59,46 +103,47 @@ func NewDNSProviderCredentials(user, key string) (*DNSProvider, error) {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second} // client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req) resp, err := config.HTTPClient.Do(req)
if err != nil { 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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 var identity Identity
err = json.NewDecoder(resp.Body).Decode(&rackspaceIdentity) err = json.NewDecoder(resp.Body).Decode(&identity)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("rackspace: %v", err)
} }
// Iterate through the Service Catalog to get the DNS Endpoint // Iterate through the Service Catalog to get the DNS Endpoint
var dnsEndpoint string var dnsEndpoint string
for _, service := range rackspaceIdentity.Access.ServiceCatalog { for _, service := range identity.Access.ServiceCatalog {
if service.Name == "cloudDNS" { if service.Name == "cloudDNS" {
dnsEndpoint = service.Endpoints[0].PublicURL dnsEndpoint = service.Endpoints[0].PublicURL
break break
} }
} }
if dnsEndpoint == "" { 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{ return &DNSProvider{
token: rackspaceIdentity.Access.Token.ID, config: config,
token: identity.Access.Token.ID,
cloudDNSEndpoint: dnsEndpoint, cloudDNSEndpoint: dnsEndpoint,
client: client,
}, nil }, nil
} }
// Present creates a TXT record to fulfil the dns-01 challenge // 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) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
zoneID, err := d.getHostedZoneID(fqdn) zoneID, err := d.getHostedZoneID(fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("rackspace: %v", err)
} }
rec := Records{ rec := Records{
@ -114,17 +159,20 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
Name: acme.UnFqdn(fqdn), Name: acme.UnFqdn(fqdn),
Type: "TXT", Type: "TXT",
Data: value, Data: value,
TTL: 300, TTL: d.config.TTL,
}}, }},
} }
body, err := json.Marshal(rec) body, err := json.Marshal(rec)
if err != nil { 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)) _, 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 // 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) fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
zoneID, err := d.getHostedZoneID(fqdn) zoneID, err := d.getHostedZoneID(fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("rackspace: %v", err)
} }
record, err := d.findTxtRecord(fqdn, zoneID) record, err := d.findTxtRecord(fqdn, zoneID)
if err != nil { 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) _, 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 // 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("X-Auth-Token", d.token)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
client := http.Client{Timeout: 30 * time.Second} resp, err := d.config.HTTPClient.Do(req)
resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("error querying DNS API: %v", err) 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 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"`
}

View file

@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
var ( var (
@ -31,13 +32,11 @@ func init() {
} }
func testRackspaceEnv() { func testRackspaceEnv() {
rackspaceAPIURL = testAPIURL
os.Setenv("RACKSPACE_USER", "testUser") os.Setenv("RACKSPACE_USER", "testUser")
os.Setenv("RACKSPACE_API_KEY", "testKey") os.Setenv("RACKSPACE_API_KEY", "testKey")
} }
func liveRackspaceEnv() { func liveRackspaceEnv() {
rackspaceAPIURL = "https://identity.api.rackspacecloud.com/v2.0/tokens"
os.Setenv("RACKSPACE_USER", rackspaceUser) os.Setenv("RACKSPACE_USER", rackspaceUser)
os.Setenv("RACKSPACE_API_KEY", rackspaceAPIKey) os.Setenv("RACKSPACE_API_KEY", rackspaceAPIKey)
} }
@ -134,31 +133,50 @@ func dnsMux() *http.ServeMux {
func TestNewDNSProviderMissingCredErr(t *testing.T) { func TestNewDNSProviderMissingCredErr(t *testing.T) {
testRackspaceEnv() 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) { func TestOfflineRackspaceValid(t *testing.T) {
testRackspaceEnv() 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") assert.Equal(t, provider.token, "testToken", "The token should match")
} }
func TestOfflineRackspacePresent(t *testing.T) { func TestOfflineRackspacePresent(t *testing.T) {
testRackspaceEnv() 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) { if assert.NoError(t, err) {
err = provider.Present("example.com", "token", "keyAuth") err = provider.Present("example.com", "token", "keyAuth")
assert.NoError(t, err) require.NoError(t, err)
} }
} }
func TestOfflineRackspaceCleanUp(t *testing.T) { func TestOfflineRackspaceCleanUp(t *testing.T) {
testRackspaceEnv() 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) { if assert.NoError(t, err) {
err = provider.CleanUp("example.com", "token", "keyAuth") err = provider.CleanUp("example.com", "token", "keyAuth")

View file

@ -3,6 +3,7 @@
package rfc2136 package rfc2136
import ( import (
"errors"
"fmt" "fmt"
"net" "net"
"os" "os"
@ -11,16 +12,37 @@ import (
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/xenolf/lego/acme" "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 // DNSProvider is an implementation of the acme.ChallengeProvider interface that
// uses dynamic DNS updates (RFC 2136) to create TXT records on a nameserver. // uses dynamic DNS updates (RFC 2136) to create TXT records on a nameserver.
type DNSProvider struct { type DNSProvider struct {
nameserver string config *Config
tsigAlgorithm string
tsigKey string
tsigSecret string
timeout time.Duration
} }
// NewDNSProvider returns a DNSProvider instance configured for rfc2136 // 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) // RFC2136_TIMEOUT: DNS propagation timeout in time.ParseDuration format. (60s)
// To disable TSIG authentication, leave the RFC2136_TSIG* variables unset. // To disable TSIG authentication, leave the RFC2136_TSIG* variables unset.
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
nameserver := os.Getenv("RFC2136_NAMESERVER") values, err := env.Get("RFC2136_NAMESERVER")
tsigAlgorithm := os.Getenv("RFC2136_TSIG_ALGORITHM") if err != nil {
tsigKey := os.Getenv("RFC2136_TSIG_KEY") return nil, fmt.Errorf("rfc2136: %v", err)
tsigSecret := os.Getenv("RFC2136_TSIG_SECRET") }
timeout := os.Getenv("RFC2136_TIMEOUT")
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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for rfc2136 dynamic update. To disable TSIG // to return a DNSProvider instance configured for rfc2136 dynamic update.
// authentication, leave the TSIG parameters as empty strings. // To disable TSIG authentication, leave the TSIG parameters as empty strings.
// nameserver must be a network address in the form "host" or "host:port". // nameserver must be a network address in the form "host" or "host:port".
func NewDNSProviderCredentials(nameserver, tsigAlgorithm, tsigKey, tsigSecret, timeout string) (*DNSProvider, error) { // Deprecated
if nameserver == "" { func NewDNSProviderCredentials(nameserver, tsigAlgorithm, tsigKey, tsigSecret, rawTimeout string) (*DNSProvider, error) {
return nil, fmt.Errorf("RFC2136 nameserver missing") config := NewDefaultConfig()
} config.Nameserver = nameserver
config.TSIGAlgorithm = tsigAlgorithm
config.TSIGKey = tsigKey
config.TSIGSecret = tsigSecret
// Append the default DNS port if none is specified. timeout := defaultTimeout
if _, _, err := net.SplitHostPort(nameserver); err != nil { if rawTimeout != "" {
if strings.Contains(err.Error(), "missing port") { t, err := time.ParseDuration(rawTimeout)
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)
if err != nil { if err != nil {
return nil, err return nil, err
} else if t < 0 { } 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 { } else {
d.timeout = t timeout = t
} }
} }
config.PropagationTimeout = timeout
return d, nil return NewDNSProviderConfig(config)
} }
// Timeout Returns the timeout configured with RFC2136_TIMEOUT, or 60s. // 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)
}
}
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 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) { 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 // Present creates a TXT record using the specified parameters
func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
return d.changeRecord("INSERT", fqdn, value, ttl)
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 // CleanUp removes the TXT record matching the specified parameters
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
return d.changeRecord("REMOVE", fqdn, value, ttl)
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 { func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error {
// Find the zone for the given fqdn // 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 { if err != nil {
return err return err
} }
@ -135,14 +186,15 @@ func (d *DNSProvider) changeRecord(action, fqdn, value string, ttl int) error {
// Setup client // Setup client
c := new(dns.Client) c := new(dns.Client)
c.SingleInflight = true c.SingleInflight = true
// TSIG authentication / msg signing // TSIG authentication / msg signing
if len(d.tsigKey) > 0 && len(d.tsigSecret) > 0 { if len(d.config.TSIGKey) > 0 && len(d.config.TSIGSecret) > 0 {
m.SetTsig(dns.Fqdn(d.tsigKey), d.tsigAlgorithm, 300, time.Now().Unix()) m.SetTsig(dns.Fqdn(d.config.TSIGKey), d.config.TSIGAlgorithm, 300, time.Now().Unix())
c.TsigSecret = map[string]string{dns.Fqdn(d.tsigKey): d.tsigSecret} c.TsigSecret = map[string]string{dns.Fqdn(d.config.TSIGKey): d.config.TSIGSecret}
} }
// Send the query // Send the query
reply, _, err := c.Exchange(m, d.nameserver) reply, _, err := c.Exchange(m, d.config.Nameserver)
if err != nil { if err != nil {
return fmt.Errorf("DNS update failed: %v", err) return fmt.Errorf("DNS update failed: %v", err)
} }

View file

@ -59,7 +59,10 @@ func TestRFC2136ServerSuccess(t *testing.T) {
require.NoError(t, err, "Failed to start test server") require.NoError(t, err, "Failed to start test server")
defer server.Shutdown() defer server.Shutdown()
provider, err := NewDNSProviderCredentials(addrstr, "", "", "", "") config := NewDefaultConfig()
config.Nameserver = addrstr
provider, err := NewDNSProviderConfig(config)
require.NoError(t, err) require.NoError(t, err)
err = provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth) err = provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth)
@ -75,7 +78,10 @@ func TestRFC2136ServerError(t *testing.T) {
require.NoError(t, err, "Failed to start test server") require.NoError(t, err, "Failed to start test server")
defer server.Shutdown() defer server.Shutdown()
provider, err := NewDNSProviderCredentials(addrstr, "", "", "", "") config := NewDefaultConfig()
config.Nameserver = addrstr
provider, err := NewDNSProviderConfig(config)
require.NoError(t, err) require.NoError(t, err)
err = provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth) err = provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth)
@ -94,7 +100,12 @@ func TestRFC2136TsigClient(t *testing.T) {
require.NoError(t, err, "Failed to start test server") require.NoError(t, err, "Failed to start test server")
defer server.Shutdown() 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) require.NoError(t, err)
err = provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth) err = provider.Present(rfc2136TestDomain, "", rfc2136TestKeyAuth)
@ -121,7 +132,10 @@ func TestRFC2136ValidUpdatePacket(t *testing.T) {
expect, err := m.Pack() expect, err := m.Pack()
require.NoError(t, err, "error packing") require.NoError(t, err, "error packing")
provider, err := NewDNSProviderCredentials(addrstr, "", "", "", "") config := NewDefaultConfig()
config.Nameserver = addrstr
provider, err := NewDNSProviderConfig(config)
require.NoError(t, err) require.NoError(t, err)
err = provider.Present(rfc2136TestDomain, "", "1234d==") err = provider.Present(rfc2136TestDomain, "", "1234d==")

View file

@ -30,13 +30,11 @@ type Config struct {
// NewDefaultConfig returns a default configuration for the DNSProvider // NewDefaultConfig returns a default configuration for the DNSProvider
func NewDefaultConfig() *Config { func NewDefaultConfig() *Config {
propagationMins := env.GetOrDefaultInt("AWS_PROPAGATION_TIMEOUT", 2)
intervalSecs := env.GetOrDefaultInt("AWS_POLLING_INTERVAL", 4)
return &Config{ return &Config{
MaxRetries: env.GetOrDefaultInt("AWS_MAX_RETRIES", 5), MaxRetries: env.GetOrDefaultInt("AWS_MAX_RETRIES", 5),
TTL: env.GetOrDefaultInt("AWS_TTL", 10), TTL: env.GetOrDefaultInt("AWS_TTL", 10),
PropagationTimeout: time.Second * time.Duration(propagationMins), PropagationTimeout: env.GetOrDefaultSecond("AWS_PROPAGATION_TIMEOUT", 2*time.Minute),
PollingInterval: time.Second * time.Duration(intervalSecs), PollingInterval: env.GetOrDefaultSecond("AWS_POLLING_INTERVAL", 4*time.Second),
HostedZoneID: os.Getenv("AWS_HOSTED_ZONE_ID"), HostedZoneID: os.Getenv("AWS_HOSTED_ZONE_ID"),
} }
} }
@ -91,20 +89,20 @@ func NewDNSProvider() (*DNSProvider, error) {
// DNSProvider instance // DNSProvider instance
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil { 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 := customRetryer{}
r.NumMaxRetries = config.MaxRetries r.NumMaxRetries = config.MaxRetries
sessionCfg := request.WithRetryer(aws.NewConfig(), r) 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 { if err != nil {
return nil, err return nil, err
} }
client := route53.New(session) cl := route53.New(sess)
return &DNSProvider{ return &DNSProvider{
client: client, client: cl,
config: config, config: config,
}, nil }, nil
} }
@ -118,15 +116,23 @@ func (r *DNSProvider) Timeout() (timeout, interval time.Duration) {
// Present creates a TXT record using the specified parameters // Present creates a TXT record using the specified parameters
func (r *DNSProvider) Present(domain, token, keyAuth string) error { func (r *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, _ := acme.DNS01Record(domain, keyAuth) 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 // CleanUp removes the TXT record matching the specified parameters
func (r *DNSProvider) CleanUp(domain, token, keyAuth string) error { func (r *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, value, _ := acme.DNS01Record(domain, keyAuth) 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 { 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) resp, err := r.client.ChangeResourceRecordSets(reqParams)
if err != nil { 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 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) resp, err := r.client.GetChange(reqParams)
if err != nil { 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 { if aws.StringValue(resp.ChangeInfo.Status) == route53.ChangeStatusInsync {
return true, nil return true, nil
@ -200,7 +206,7 @@ func (r *DNSProvider) getHostedZoneID(fqdn string) (string, error) {
} }
if len(hostedZoneID) == 0 { 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/") { if strings.HasPrefix(hostedZoneID, "/hostedzone/") {

View file

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/sacloud/libsacloud/api" "github.com/sacloud/libsacloud/api"
"github.com/sacloud/libsacloud/sacloud" "github.com/sacloud/libsacloud/sacloud"
@ -14,8 +15,27 @@ import (
"github.com/xenolf/lego/platform/config/env" "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. // DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProvider struct { type DNSProvider struct {
config *Config
client *api.Client client *api.Client
} }
@ -24,23 +44,42 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("SAKURACLOUD_ACCESS_TOKEN", "SAKURACLOUD_ACCESS_TOKEN_SECRET") values, err := env.Get("SAKURACLOUD_ACCESS_TOKEN", "SAKURACLOUD_ACCESS_TOKEN_SECRET")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for sakuracloud. // to return a DNSProvider instance configured for sakuracloud.
// Deprecated
func NewDNSProviderCredentials(token, secret string) (*DNSProvider, error) { func NewDNSProviderCredentials(token, secret string) (*DNSProvider, error) {
if token == "" { config := NewDefaultConfig()
return nil, errors.New("SakuraCloud AccessToken is missing") config.Token = token
} config.Secret = secret
if secret == "" {
return nil, errors.New("SakuraCloud AccessSecret is missing") 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 client.UserAgent = acme.UserAgent
return &DNSProvider{client: client}, nil 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. // Present creates a TXT record to fulfil the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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) zone, err := d.getHostedZone(domain)
if err != nil { if err != nil {
return err return fmt.Errorf("sakuracloud: %v", err)
} }
name := d.extractRecordName(fqdn, zone.Name) 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) _, err = d.client.GetDNSAPI().Update(zone.ID, zone)
if err != nil { if err != nil {
return fmt.Errorf("SakuraCloud API call failed: %v", err) return fmt.Errorf("sakuracloud: API call failed: %v", err)
} }
return nil return nil
@ -72,12 +111,12 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
zone, err := d.getHostedZone(domain) zone, err := d.getHostedZone(domain)
if err != nil { if err != nil {
return err return fmt.Errorf("sakuracloud: %v", err)
} }
records, err := d.findTxtRecords(fqdn, zone) records, err := d.findTxtRecords(fqdn, zone)
if err != nil { if err != nil {
return err return fmt.Errorf("sakuracloud: %v", err)
} }
for _, record := range records { 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) _, err = d.client.GetDNSAPI().Update(zone.ID, zone)
if err != nil { if err != nil {
return fmt.Errorf("SakuraCloud API call failed: %v", err) return fmt.Errorf("sakuracloud: API call failed: %v", err)
} }
return nil 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) { func (d *DNSProvider) getHostedZone(domain string) (*sacloud.DNS, error) {
authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers)
if err != nil { 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 { 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("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 { 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) { func (d *DNSProvider) findTxtRecords(fqdn string, zone *sacloud.DNS) ([]sacloud.DNSRecordSet, error) {

View file

@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
) )
@ -54,7 +55,7 @@ func TestNewDNSProviderInvalidWithMissingAccessToken(t *testing.T) {
provider, err := NewDNSProvider() provider, err := NewDNSProvider()
assert.Nil(t, provider) 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) { 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.NotNil(t, provider)
assert.Equal(t, acme.UserAgent, provider.client.UserAgent) assert.Equal(t, acme.UserAgent, provider.client.UserAgent)
assert.NoError(t, err)
} }
func TestNewDNSProviderCredentialsInvalidWithMissingAccessToken(t *testing.T) { func TestNewDNSProviderCredentialsInvalidWithMissingAccessToken(t *testing.T) {
provider, err := NewDNSProviderCredentials("", "") config := NewDefaultConfig()
provider, err := NewDNSProviderConfig(config)
assert.Nil(t, provider) 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") 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) assert.NoError(t, err)
err = provider.Present(sakuracloudDomain, "", "123d==") err = provider.Present(sakuracloudDomain, "", "123d==")
@ -103,7 +113,11 @@ func TestLiveSakuraCloudCleanUp(t *testing.T) {
time.Sleep(time.Second * 1) 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) assert.NoError(t, err)
err = provider.CleanUp(sakuracloudDomain, "", "123d==") err = provider.CleanUp(sakuracloudDomain, "", "123d==")

View file

@ -3,6 +3,7 @@
package vegadns package vegadns
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@ -13,8 +14,28 @@ import (
"github.com/xenolf/lego/platform/config/env" "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 // DNSProvider describes a provider for VegaDNS
type DNSProvider struct { type DNSProvider struct {
config *Config
client vegaClient.VegaDNSClient client vegaClient.VegaDNSClient
} }
@ -24,62 +45,83 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("VEGADNS_URL") values, err := env.Get("VEGADNS_URL")
if err != nil { if err != nil {
return nil, fmt.Errorf("VegaDNS: %v", err) return nil, fmt.Errorf("vegadns: %v", err)
} }
key := os.Getenv("SECRET_VEGADNS_KEY") config := NewDefaultConfig()
secret := os.Getenv("SECRET_VEGADNS_SECRET") 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 // NewDNSProviderCredentials uses the supplied credentials
// DNSProvider instance configured for VegaDNS. // to return a DNSProvider instance configured for VegaDNS.
// Deprecated
func NewDNSProviderCredentials(vegaDNSURL string, key string, secret string) (*DNSProvider, error) { func NewDNSProviderCredentials(vegaDNSURL string, key string, secret string) (*DNSProvider, error) {
vega := vegaClient.NewVegaDNSClient(vegaDNSURL) config := NewDefaultConfig()
vega.APIKey = key config.BaseURL = vegaDNSURL
vega.APISecret = secret config.APIKey = key
config.APISecret = secret
return &DNSProvider{ return NewDNSProviderConfig(config)
client: vega,
}, nil
} }
// Timeout returns the timeout and interval to use when checking for DNS // NewDNSProviderConfig return a DNSProvider instance configured for VegaDNS.
// propagation. Adjusting here to cope with spikes in propagation times. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
func (r *DNSProvider) Timeout() (timeout, interval time.Duration) { if config == nil {
timeout = 12 * time.Minute return nil, errors.New("vegadns: the configuration of the DNS provider is nil")
interval = 1 * time.Minute }
return
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 // 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) fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
_, domainID, err := r.client.GetAuthZone(fqdn) _, domainID, err := d.client.GetAuthZone(fqdn)
if err != nil { 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 // 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) fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
_, domainID, err := r.client.GetAuthZone(fqdn) _, domainID, err := d.client.GetAuthZone(fqdn)
if err != nil { 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, ".") txt := strings.TrimSuffix(fqdn, ".")
recordID, err := r.client.GetRecordID(domainID, txt, "TXT") recordID, err := d.client.GetRecordID(domainID, txt, "TXT")
if err != nil { 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
} }

View file

@ -147,7 +147,7 @@ func TestVegaDNSPresentFailToFindZone(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
err = provider.Present("example.com", "token", "keyAuth") 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) { func TestVegaDNSPresentFailToCreateTXT(t *testing.T) {
@ -161,7 +161,7 @@ func TestVegaDNSPresentFailToCreateTXT(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
err = provider.Present("example.com", "token", "keyAuth") 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) { func TestVegaDNSCleanUpSuccess(t *testing.T) {
@ -189,7 +189,7 @@ func TestVegaDNSCleanUpFailToFindZone(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
err = provider.CleanUp("example.com", "token", "keyAuth") 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) { func TestVegaDNSCleanUpFailToGetRecordID(t *testing.T) {
@ -203,7 +203,7 @@ func TestVegaDNSCleanUpFailToGetRecordID(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
err = provider.CleanUp("example.com", "token", "keyAuth") 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 { func vegaDNSMuxSuccess() *http.ServeMux {

View file

@ -4,16 +4,46 @@
package vultr package vultr
import ( import (
"crypto/tls"
"errors"
"fmt" "fmt"
"net/http"
"strings" "strings"
"time"
vultr "github.com/JamesClonk/vultr/lib" vultr "github.com/JamesClonk/vultr/lib"
"github.com/xenolf/lego/acme" "github.com/xenolf/lego/acme"
"github.com/xenolf/lego/platform/config/env" "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. // DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProvider struct { type DNSProvider struct {
config *Config
client *vultr.Client client *vultr.Client
} }
@ -22,36 +52,58 @@ type DNSProvider struct {
func NewDNSProvider() (*DNSProvider, error) { func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("VULTR_API_KEY") values, err := env.Get("VULTR_API_KEY")
if err != nil { 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 // NewDNSProviderCredentials uses the supplied credentials
// instance configured for Vultr. // to return a DNSProvider instance configured for Vultr.
// Deprecated
func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) {
if apiKey == "" { config := NewDefaultConfig()
return nil, fmt.Errorf("Vultr credentials missing") 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. // Present creates a TXT record to fulfil the DNS-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error { 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) zoneDomain, err := d.getHostedZone(domain)
if err != nil { if err != nil {
return err return fmt.Errorf("vultr: %v", err)
} }
name := d.extractRecordName(fqdn, zoneDomain) 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 { if err != nil {
return fmt.Errorf("Vultr API call failed: %v", err) return fmt.Errorf("vultr: API call failed: %v", err)
} }
return nil return nil
@ -63,22 +115,34 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
zoneDomain, records, err := d.findTxtRecords(domain, fqdn) zoneDomain, records, err := d.findTxtRecords(domain, fqdn)
if err != nil { if err != nil {
return err return fmt.Errorf("vultr: %v", err)
} }
var allErr []string
for _, rec := range records { for _, rec := range records {
err := d.client.DeleteDNSRecord(zoneDomain, rec.RecordID) err := d.client.DeleteDNSRecord(zoneDomain, rec.RecordID)
if err != nil { if err != nil {
return err allErr = append(allErr, err.Error())
} }
} }
if len(allErr) > 0 {
return errors.New(strings.Join(allErr, ": "))
}
return nil 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) { func (d *DNSProvider) getHostedZone(domain string) (string, error) {
domains, err := d.client.GetDNSDomains() domains, err := d.client.GetDNSDomains()
if err != nil { if err != nil {
return "", fmt.Errorf("Vultr API call failed: %v", err) return "", fmt.Errorf("API call failed: %v", err)
} }
var hostedDomain vultr.DNSDomain var hostedDomain vultr.DNSDomain
@ -90,7 +154,7 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) {
} }
} }
if hostedDomain.Domain == "" { 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 return hostedDomain.Domain, nil
@ -105,7 +169,7 @@ func (d *DNSProvider) findTxtRecords(domain, fqdn string) (string, []vultr.DNSRe
var records []vultr.DNSRecord var records []vultr.DNSRecord
result, err := d.client.GetDNSRecords(zoneDomain) result, err := d.client.GetDNSRecords(zoneDomain)
if err != nil { 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) recordName := d.extractRecordName(fqdn, zoneDomain)

View file

@ -37,7 +37,7 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("VULTR_API_KEY", "") os.Setenv("VULTR_API_KEY", "")
_, err := NewDNSProvider() _, 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) { func TestLivePresent(t *testing.T) {