1
0
mirror of https://github.com/go-acme/lego.git synced 2024-12-23 01:07:23 +02:00

Add DNS Provider for ClouDNS.net (#813)

This commit is contained in:
tzakrajs 2019-03-01 10:09:00 -08:00 committed by Ludovic Fernandez
parent 52eceeb8d2
commit 1c309c9c80
8 changed files with 668 additions and 1 deletions

View File

@ -85,6 +85,7 @@ git push -u origin my-feature
| Azure | `azure` | [documentation](https://docs.microsoft.com/en-us/go/azure/) | [Go client](https://github.com/Azure/azure-sdk-for-go) |
| Bluecat | `bluecat` | ? | - |
| Cloudflare | `cloudflare` | [documentation](https://api.cloudflare.com/) | [Go client](https://github.com/cloudflare/cloudflare-go) |
| ClouDNS | `cloudns` | [documentation](https://www.cloudns.net/wiki/article/42/) | - |
| CloudXNS | `cloudxns` | [documentation](https://www.cloudxns.net/Public/Doc/CloudXNS_api2.0_doc_zh-cn.zip) | - |
| ConoHa | `conoha` | [documentation](https://www.conoha.jp/docs/) | - |
| Openstack Designate | `designate` | [documentation](https://docs.openstack.org/designate/latest/) | [Go client](https://godoc.org/github.com/gophercloud/gophercloud/openstack/dns/v2) |

View File

@ -37,6 +37,7 @@ Here is an example bash command using the CloudFlare DNS provider:
fmt.Fprintln(w, "\tazure:\tAZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, AZURE_RESOURCE_GROUP")
fmt.Fprintln(w, "\tbluecat:\tBLUECAT_SERVER_URL, BLUECAT_USER_NAME, BLUECAT_PASSWORD, BLUECAT_CONFIG_NAME, BLUECAT_DNS_VIEW")
fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY")
fmt.Fprintln(w, "\tcloudns:\tCLOUDNS_AUTH_ID, CLOUDNS_AUTH_PASSWORD")
fmt.Fprintln(w, "\tcloudxns:\tCLOUDXNS_API_KEY, CLOUDXNS_SECRET_KEY")
fmt.Fprintln(w, "\tconoha:\tCONOHA_TENANT_ID, CONOHA_API_USERNAME, CONOHA_API_PASSWORD")
fmt.Fprintln(w, "\tdesignate:\tOS_AUTH_URL, OS_USERNAME, OS_PASSWORD, OS_TENANT_NAME, OS_REGION_NAME")
@ -92,6 +93,7 @@ Here is an example bash command using the CloudFlare DNS provider:
fmt.Fprintln(w, "\tazure:\tAZURE_POLLING_INTERVAL, AZURE_PROPAGATION_TIMEOUT, AZURE_TTL, AZURE_METADATA_ENDPOINT")
fmt.Fprintln(w, "\tbluecat:\tBLUECAT_POLLING_INTERVAL, BLUECAT_PROPAGATION_TIMEOUT, BLUECAT_TTL, BLUECAT_HTTP_TIMEOUT")
fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_POLLING_INTERVAL, CLOUDFLARE_PROPAGATION_TIMEOUT, CLOUDFLARE_TTL, CLOUDFLARE_HTTP_TIMEOUT")
fmt.Fprintln(w, "\tcloudns:\tCLOUDNS_POLLING_INTERVAL, CLOUDNS_PROPAGATION_TIMEOUT, CLOUDNS_TTL, CLOUDNS_HTTP_TIMEOUT")
fmt.Fprintln(w, "\tcloudxns:\tCLOUDXNS_POLLING_INTERVAL, CLOUDXNS_PROPAGATION_TIMEOUT, CLOUDXNS_TTL, CLOUDXNS_HTTP_TIMEOUT")
fmt.Fprintln(w, "\tconoha:\tCONOHA_POLLING_INTERVAL, CONOHA_PROPAGATION_TIMEOUT, CONOHA_TTL, CONOHA_HTTP_TIMEOUT, CONOHA_REGION")
fmt.Fprintln(w, "\tdesignate:\tDESIGNATE_POLLING_INTERVAL, DESIGNATE_PROPAGATION_TIMEOUT, DESIGNATE_TTL")

View File

@ -0,0 +1,108 @@
// Package cloudns implements a DNS provider for solving the DNS-01 challenge using ClouDNS DNS.
package cloudns
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/xenolf/lego/challenge/dns01"
"github.com/xenolf/lego/platform/config/env"
"github.com/xenolf/lego/providers/dns/cloudns/internal"
)
// Config is used to configure the creation of the DNSProvider
type Config struct {
AuthID string
AuthPassword 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{
PropagationTimeout: env.GetOrDefaultSecond("CLOUDNS_PROPAGATION_TIMEOUT", 120*time.Second),
PollingInterval: env.GetOrDefaultSecond("CLOUDNS_POLLING_INTERVAL", 4*time.Second),
TTL: env.GetOrDefaultInt("CLOUDNS_TTL", dns01.DefaultTTL),
HTTPClient: &http.Client{
Timeout: env.GetOrDefaultSecond("CLOUDNS_HTTP_TIMEOUT", 30*time.Second),
},
}
}
// DNSProvider is an implementation of the acme.ChallengeProvider interface
type DNSProvider struct {
config *Config
client *internal.Client
}
// NewDNSProvider returns a DNSProvider instance configured for ClouDNS.
// Credentials must be passed in the environment variables:
// CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD.
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("CLOUDNS_AUTH_ID", "CLOUDNS_AUTH_PASSWORD")
if err != nil {
return nil, fmt.Errorf("ClouDNS: %v", err)
}
config := NewDefaultConfig()
config.AuthID = values["CLOUDNS_AUTH_ID"]
config.AuthPassword = values["CLOUDNS_AUTH_PASSWORD"]
return NewDNSProviderConfig(config)
}
// NewDNSProviderConfig return a DNSProvider instance configured for ClouDNS.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("ClouDNS: the configuration of the DNS provider is nil")
}
client, err := internal.NewClient(config.AuthID, config.AuthPassword)
if err != nil {
return nil, err
}
client.HTTPClient = config.HTTPClient
return &DNSProvider{client: client, config: config}, nil
}
// Present creates a TXT record to fulfill the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value := dns01.GetRecord(domain, keyAuth)
zone, err := d.client.GetZone(fqdn)
if err != nil {
return err
}
return d.client.AddTxtRecord(zone.Name, fqdn, value, d.config.TTL)
}
// CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _ := dns01.GetRecord(domain, keyAuth)
zone, err := d.client.GetZone(fqdn)
if err != nil {
return err
}
record, err := d.client.FindTxtRecord(zone.Name, fqdn)
if err != nil {
return err
}
return d.client.RemoveTxtRecord(record.ID, zone.Name)
}
// 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

@ -0,0 +1,150 @@
package cloudns
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/xenolf/lego/platform/tester"
)
var envTest = tester.NewEnvTest(
"CLOUDNS_AUTH_ID",
"CLOUDNS_AUTH_PASSWORD").
WithDomain("CLOUDNS_DOMAIN")
func TestNewDNSProvider(t *testing.T) {
testCases := []struct {
desc string
envVars map[string]string
expected string
}{
{
desc: "success",
envVars: map[string]string{
"CLOUDNS_AUTH_ID": "123",
"CLOUDNS_AUTH_PASSWORD": "456",
},
},
{
desc: "missing credentials",
envVars: map[string]string{
"CLOUDNS_AUTH_ID": "",
"CLOUDNS_AUTH_PASSWORD": "",
},
expected: "ClouDNS: some credentials information are missing: CLOUDNS_AUTH_ID,CLOUDNS_AUTH_PASSWORD",
},
{
desc: "missing auth-id",
envVars: map[string]string{
"CLOUDNS_AUTH_ID": "",
"CLOUDNS_AUTH_PASSWORD": "456",
},
expected: "ClouDNS: some credentials information are missing: CLOUDNS_AUTH_ID",
},
{
desc: "missing auth-password",
envVars: map[string]string{
"CLOUDNS_AUTH_ID": "123",
"CLOUDNS_AUTH_PASSWORD": "",
},
expected: "ClouDNS: some credentials information are missing: CLOUDNS_AUTH_PASSWORD",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
defer envTest.RestoreEnv()
envTest.ClearEnv()
envTest.Apply(test.envVars)
p, err := NewDNSProvider()
if len(test.expected) == 0 {
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
require.NotNil(t, p.client)
} else {
require.EqualError(t, err, test.expected)
}
})
}
}
func TestNewDNSProviderConfig(t *testing.T) {
testCases := []struct {
desc string
authID string
authPassword string
expected string
}{
{
desc: "success",
authID: "123",
authPassword: "456",
},
{
desc: "missing credentials",
expected: "ClouDNS: credentials missing: authID",
},
{
desc: "missing auth-id",
authPassword: "456",
expected: "ClouDNS: credentials missing: authID",
},
{
desc: "missing auth-password",
authID: "123",
expected: "ClouDNS: credentials missing: authPassword",
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
config := NewDefaultConfig()
config.AuthID = test.authID
config.AuthPassword = test.authPassword
p, err := NewDNSProviderConfig(config)
if len(test.expected) == 0 {
require.NoError(t, err)
require.NotNil(t, p)
require.NotNil(t, p.config)
require.NotNil(t, p.client)
} else {
require.EqualError(t, err, test.expected)
}
})
}
}
func TestLivePresent(t *testing.T) {
if !envTest.IsLiveTest() {
t.Skip("skipping live test")
}
envTest.RestoreEnv()
provider, err := NewDNSProvider()
require.NoError(t, err)
err = provider.Present(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}
func TestLiveCleanUp(t *testing.T) {
if !envTest.IsLiveTest() {
t.Skip("skipping live test")
}
envTest.RestoreEnv()
provider, err := NewDNSProvider()
require.NoError(t, err)
time.Sleep(2 * time.Second)
err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
require.NoError(t, err)
}

View File

@ -0,0 +1,209 @@
package internal
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/xenolf/lego/challenge/dns01"
)
const defaultBaseURL = "https://api.cloudns.net/dns/"
type Zone struct {
Name string
Type string
Zone string
Status string // is an integer, but cast as string
}
// TXTRecord a TXT record
type TXTRecord struct {
ID int `json:"id,string"`
Type string `json:"type"`
Host string `json:"host"`
Record string `json:"record"`
Failover int `json:"failover,string"`
TTL int `json:"ttl,string"`
Status int `json:"status"`
}
type TXTRecords map[string]TXTRecord
// NewClient creates a ClouDNS client
func NewClient(authID string, authPassword string) (*Client, error) {
if authID == "" {
return nil, fmt.Errorf("ClouDNS: credentials missing: authID")
}
if authPassword == "" {
return nil, fmt.Errorf("ClouDNS: credentials missing: authPassword")
}
baseURL, err := url.Parse(defaultBaseURL)
if err != nil {
return nil, err
}
return &Client{
authID: authID,
authPassword: authPassword,
HTTPClient: &http.Client{},
BaseURL: baseURL,
}, nil
}
// Client ClouDNS client
type Client struct {
authID string
authPassword string
HTTPClient *http.Client
BaseURL *url.URL
}
// GetZone Get domain name information for a FQDN
func (c *Client) GetZone(authFQDN string) (*Zone, error) {
authZone, err := dns01.FindZoneByFqdn(authFQDN)
if err != nil {
return nil, err
}
authZoneName := dns01.UnFqdn(authZone)
reqURL := *c.BaseURL
reqURL.Path += "get-zone-info.json"
q := reqURL.Query()
q.Add("domain-name", authZoneName)
reqURL.RawQuery = q.Encode()
result, err := c.doRequest(http.MethodGet, &reqURL)
if err != nil {
return nil, err
}
var zone Zone
if len(result) > 0 {
if err = json.Unmarshal(result, &zone); err != nil {
return nil, fmt.Errorf("ClouDNS: zone unmarshaling error: %v", err)
}
}
if zone.Name == authZoneName {
return &zone, nil
}
return nil, fmt.Errorf("ClouDNS: zone %s not found for authFQDN %s", authZoneName, authFQDN)
}
// FindTxtRecord return the TXT record a zone ID and a FQDN
func (c *Client) FindTxtRecord(zoneName, fqdn string) (*TXTRecord, error) {
host := dns01.UnFqdn(strings.TrimSuffix(dns01.UnFqdn(fqdn), zoneName))
reqURL := *c.BaseURL
reqURL.Path += "records.json"
q := reqURL.Query()
q.Add("domain-name", zoneName)
q.Add("host", host)
q.Add("type", "TXT")
reqURL.RawQuery = q.Encode()
result, err := c.doRequest(http.MethodGet, &reqURL)
if err != nil {
return nil, err
}
var records TXTRecords
if err = json.Unmarshal(result, &records); err != nil {
return nil, fmt.Errorf("ClouDNS: TXT record unmarshaling error: %v", err)
}
for _, record := range records {
if record.Host == host && record.Type == "TXT" {
return &record, nil
}
}
return nil, fmt.Errorf("ClouDNS: no existing record found for %q", fqdn)
}
// AddTxtRecord add a TXT record
func (c *Client) AddTxtRecord(zoneName string, fqdn, value string, ttl int) error {
host := dns01.UnFqdn(strings.TrimSuffix(dns01.UnFqdn(fqdn), zoneName))
reqURL := *c.BaseURL
reqURL.Path += "add-record.json"
q := reqURL.Query()
q.Add("domain-name", zoneName)
q.Add("host", host)
q.Add("record", value)
q.Add("ttl", strconv.Itoa(ttl))
q.Add("record-type", "TXT")
reqURL.RawQuery = q.Encode()
_, err := c.doRequest(http.MethodPost, &reqURL)
return err
}
// RemoveTxtRecord remove a TXT record
func (c *Client) RemoveTxtRecord(recordID int, zoneName string) error {
reqURL := *c.BaseURL
reqURL.Path += "delete-record.json"
q := reqURL.Query()
q.Add("domain-name", zoneName)
q.Add("record-id", strconv.Itoa(recordID))
reqURL.RawQuery = q.Encode()
_, err := c.doRequest(http.MethodPost, &reqURL)
return err
}
func (c *Client) doRequest(method string, url *url.URL) (json.RawMessage, error) {
req, err := c.buildRequest(method, url)
if err != nil {
return nil, err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("ClouDNS: %v", err)
}
defer resp.Body.Close()
content, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("ClouDNS: %s", toUnreadableBodyMessage(req, content))
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("ClouDNS: invalid code (%v), error: %s", resp.StatusCode, content)
}
return content, nil
}
func (c *Client) buildRequest(method string, url *url.URL) (*http.Request, error) {
q := url.Query()
q.Add("auth-id", c.authID)
q.Add("auth-password", c.authPassword)
url.RawQuery = q.Encode()
req, err := http.NewRequest(method, url.String(), nil)
if err != nil {
return nil, fmt.Errorf("ClouDNS: invalid request: %v", err)
}
return req, 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))
}

View File

@ -0,0 +1,194 @@
package internal
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func handlerMock(method string, jsonData []byte) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
if req.Method != method {
http.Error(rw, "Incorrect method used", http.StatusBadRequest)
return
}
_, err := rw.Write(jsonData)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
})
}
func TestClientGetZone(t *testing.T) {
type result struct {
zone *Zone
error bool
}
testCases := []struct {
desc string
authFQDN string
apiResponse []byte
expected result
}{
{
desc: "zone found",
authFQDN: "_acme-challenge.foo.com.",
apiResponse: []byte(`{"name": "foo.com", "type": "master", "zone": "zone", "status": "1"}`),
expected: result{
zone: &Zone{
Name: "foo.com",
Type: "master",
Zone: "zone",
Status: "1",
},
},
},
{
desc: "zone not found",
authFQDN: "_acme-challenge.foo.com.",
apiResponse: []byte(``),
expected: result{error: true},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
server := httptest.NewServer(handlerMock(http.MethodGet, test.apiResponse))
client, _ := NewClient("myAuthID", "myAuthPassword")
mockBaseURL, _ := url.Parse(fmt.Sprintf("%s/", server.URL))
client.BaseURL = mockBaseURL
zone, err := client.GetZone(test.authFQDN)
if test.expected.error {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected.zone, zone)
}
})
}
}
func TestClientFindTxtRecord(t *testing.T) {
type result struct {
txtRecord *TXTRecord
error bool
}
testCases := []struct {
desc string
authFQDN string
zoneName string
apiResponse []byte
expected result
}{
{
desc: "record found",
authFQDN: "_acme-challenge.foo.com.",
zoneName: "foo.com",
apiResponse: []byte(`{"1":{"id":"1","type":"TXT","host":"_acme-challenge","record":"txtTXTtxtTXTtxtTXTtxtTXT","failover":"1","ttl":"30","status":1}}`),
expected: result{
txtRecord: &TXTRecord{
ID: 1,
Type: "TXT",
Host: "_acme-challenge",
Record: "txtTXTtxtTXTtxtTXTtxtTXT",
Failover: 1,
TTL: 30,
Status: 1,
},
},
},
{
desc: "record not found",
authFQDN: "_acme-challenge.foo.com.",
zoneName: "test-zone",
apiResponse: []byte(``),
expected: result{error: true},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
server := httptest.NewServer(handlerMock(http.MethodGet, test.apiResponse))
client, _ := NewClient("myAuthID", "myAuthPassword")
mockBaseURL, _ := url.Parse(fmt.Sprintf("%s/", server.URL))
client.BaseURL = mockBaseURL
txtRecord, err := client.FindTxtRecord(test.zoneName, test.authFQDN)
if test.expected.error {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.expected.txtRecord, txtRecord)
}
})
}
}
func TestClientAddTxtRecord(t *testing.T) {
testCases := []struct {
desc string
zone *Zone
authFQDN string
value string
ttl int
expected string
}{
{
desc: "sub-zone",
zone: &Zone{
Name: "bar.com",
Type: "master",
Zone: "domain",
Status: "1",
},
authFQDN: "_acme-challenge.foo.bar.com.",
value: "txtTXTtxtTXTtxtTXTtxtTXT",
ttl: 60,
expected: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge.foo&record=txtTXTtxtTXTtxtTXTtxtTXT&record-type=TXT&ttl=60`,
},
{
desc: "main zone",
zone: &Zone{
Name: "bar.com",
Type: "master",
Zone: "domain",
Status: "1",
},
authFQDN: "_acme-challenge.bar.com.",
value: "TXTtxtTXTtxtTXTtxtTXTtxt",
ttl: 60,
expected: `auth-id=myAuthID&auth-password=myAuthPassword&domain-name=bar.com&host=_acme-challenge&record=TXTtxtTXTtxtTXTtxtTXTtxt&record-type=TXT&ttl=60`,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
assert.NotNil(t, req.URL.RawQuery)
assert.Equal(t, test.expected, req.URL.RawQuery)
handlerMock(http.MethodPost, nil).ServeHTTP(rw, req)
}))
client, _ := NewClient("myAuthID", "myAuthPassword")
mockBaseURL, _ := url.Parse(fmt.Sprintf("%s/", server.URL))
client.BaseURL = mockBaseURL
err := client.AddTxtRecord(test.zone.Name, test.authFQDN, test.value, test.ttl)
require.NoError(t, err)
})
}
}

View File

@ -29,7 +29,7 @@ func NewDefaultConfig() *Config {
PollingInterval: env.GetOrDefaultSecond("CLOUDXNS_POLLING_INTERVAL", dns01.DefaultPollingInterval),
TTL: env.GetOrDefaultInt("CLOUDXNS_TTL", dns01.DefaultTTL),
HTTPClient: &http.Client{
Timeout: time.Second * time.Duration(env.GetOrDefaultInt("CLOUDXNS_HTTP_TIMEOUT", 30)),
Timeout: env.GetOrDefaultSecond("CLOUDXNS_HTTP_TIMEOUT", 30*time.Second),
},
}
}

View File

@ -11,6 +11,7 @@ import (
"github.com/xenolf/lego/providers/dns/azure"
"github.com/xenolf/lego/providers/dns/bluecat"
"github.com/xenolf/lego/providers/dns/cloudflare"
"github.com/xenolf/lego/providers/dns/cloudns"
"github.com/xenolf/lego/providers/dns/cloudxns"
"github.com/xenolf/lego/providers/dns/conoha"
"github.com/xenolf/lego/providers/dns/designate"
@ -74,6 +75,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return bluecat.NewDNSProvider()
case "cloudflare":
return cloudflare.NewDNSProvider()
case "cloudns":
return cloudns.NewDNSProvider()
case "cloudxns":
return cloudxns.NewDNSProvider()
case "conoha":