Repository: xenolf/lego
Pull request #698 (2018-11-02 16:32:27)

Support DNS Provider Selectel
Hi, this PR contains support for passing DNS challenge via Selectel(https://selectel.ru/en/) provider.

There is a tiny HTTP client in PR, written just for the sake of simplicity, but I'll remove it as soon as we will add Selectel Domains API client to our [account](https://github.com/selectel). 

Running example:

```console
$ ./lego -m dstdfx@gmail.com --dns selectel -s https://acme-staging-v02.api.letsencrypt.org/directory -d dstdfx.space -d *.dstdfx.space run
2018/11/02 18:56:55 Please review the TOS at https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf
2018/11/02 18:56:55 Do you accept the TOS? Y/n
Y
2018/11/02 18:56:57 [INFO] acme: Registering account for dstdfx@gmail.com
2018/11/02 18:56:58 !!!! HEADS UP !!!!
2018/11/02 18:56:58 
                Your account credentials have been saved in your Let's Encrypt
                configuration directory at "/Users/dstdfx/go/src/github.com/dstdfx/lego/.lego/accounts/acme-staging-v02.api.letsencrypt.org/dstdfx@gmail.com".
                You should make a secure backup of this folder now. This
                configuration directory will also contain certificates and
                private keys obtained from Let's Encrypt so making regular
                backups of this folder is ideal.
2018/11/02 18:56:58 [INFO] [dstdfx.space, *.dstdfx.space] acme: Obtaining bundled SAN certificate
2018/11/02 18:56:58 [INFO] [*.dstdfx.space] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz/MyaHg7EJrxKATImLIoScSwuFyH7UxjWlh9Cpat_FBbY
2018/11/02 18:56:58 [INFO] [dstdfx.space] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz/RH80duNbvxFr0n4t83M2yb91-UbpNG0UkUEPmcuAAX0
2018/11/02 18:56:58 [INFO] [dstdfx.space] acme: Could not find solver for: tls-alpn-01
2018/11/02 18:56:58 [INFO] [dstdfx.space] acme: Preparing to solve DNS-01
2018/11/02 18:56:59 [INFO] [dstdfx.space] acme: Preparing to solve DNS-01
2018/11/02 18:56:59 [INFO] [dstdfx.space] acme: Trying to solve DNS-01
2018/11/02 18:56:59 [INFO] [dstdfx.space] Checking DNS record propagation using [10.0.9.16:53 188.93.16.19:53 188.93.17.19:53]
2018/11/02 18:56:59 [INFO] Wait [timeout: 2m0s, interval: 2s]
2018/11/02 18:57:25 [INFO] [dstdfx.space] The server validated our request
2018/11/02 18:57:25 [INFO] [dstdfx.space] acme: Trying to solve DNS-01
2018/11/02 18:57:25 [INFO] [dstdfx.space] Checking DNS record propagation using [10.0.9.16:53 188.93.16.19:53 188.93.17.19:53]
2018/11/02 18:57:25 [INFO] Wait [timeout: 2m0s, interval: 2s]
2018/11/02 18:57:31 [INFO] [dstdfx.space] The server validated our request
2018/11/02 18:57:31 [INFO] [dstdfx.space, *.dstdfx.space] acme: Validations succeeded; requesting certificates
2018/11/02 18:57:38 [INFO] [dstdfx.space] Server responded with a certificate.
```


ldez: Could you do a test with this command:

```bash
./lego -m your@email.com -a -x http-01 -x tls-alpn-01 --dns selectel -s https://acme-staging-v02.api.letsencrypt.org/directory -d mydomain.com -d *.mydomain.com run
```


ldez: The [API documentation](https://my.selectel.ru/domains/doc) is private (require an account) I'm not confident with that.
dstdfx: @ldez added links to the public knowledge base

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9b574af7..084cdc70 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -76,6 +76,7 @@ owners to license your work under the terms of the [MIT License](LICENSE).
 | RFC2136                   | `rfc2136`      | [documentation](https://tools.ietf.org/html/rfc2136)                                                         | -                                                                 |
 | Route 53                  | `route53`      | [documentation](https://docs.aws.amazon.com/Route53/latest/APIReference/API_Operations_Amazon_Route_53.html) | [Go client](https://github.com/aws/aws-sdk-go/aws)                |
 | Sakura Cloud              | `sakuracloud`  | [documentation](https://developer.sakura.ad.jp/cloud/api/1.1/)                                               | [Go client](https://github.com/sacloud/libsacloud)                |
+| Selectel                  | `selectel`     | [documentation](https://kb.selectel.com/23136054.html)                                                          | -                                                                 |
 | Stackpath                 | `stackpath`    | [documentation](https://developer.stackpath.com/en/api/dns/#tag/Zone)                                        | -                                                                 |
 | VegaDNS                   | `vegadns`      | [documentation](https://github.com/shupp/VegaDNS-API)                                                        | [Go client](https://github.com/OpenDNS/vegadns2client)            |
 | Vultr                     | `vultr`        | [documentation](https://www.vultr.com/api/#dns)                                                              | [Go client](https://github.com/JamesClonk/vultr)                  |
diff --git a/cli.go b/cli.go
index 6f553913..4ed204e1 100644
--- a/cli.go
+++ b/cli.go
@@ -244,6 +244,7 @@ Here is an example bash command using the CloudFlare DNS provider:
 	fmt.Fprintln(w, "\troute53:\tAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_HOSTED_ZONE_ID")
 	fmt.Fprintln(w, "\tsakuracloud:\tSAKURACLOUD_ACCESS_TOKEN, SAKURACLOUD_ACCESS_TOKEN_SECRET")
 	fmt.Fprintln(w, "\tstackpath:\tSTACKPATH_CLIENT_ID, STACKPATH_CLIENT_SECRET, STACKPATH_STACK_ID")
+	fmt.Fprintln(w, "\tselectel:\tSELECTEL_API_TOKEN")
 	fmt.Fprintln(w, "\tvegadns:\tSECRET_VEGADNS_KEY, SECRET_VEGADNS_SECRET, VEGADNS_URL")
 	fmt.Fprintln(w, "\tvultr:\tVULTR_API_KEY")
 	fmt.Fprintln(w)
@@ -288,6 +289,7 @@ Here is an example bash command using the CloudFlare DNS provider:
 	fmt.Fprintln(w, "\troute53:\tAWS_POLLING_INTERVAL, AWS_PROPAGATION_TIMEOUT, AWS_TTL")
 	fmt.Fprintln(w, "\tsakuracloud:\tSAKURACLOUD_POLLING_INTERVAL, SAKURACLOUD_PROPAGATION_TIMEOUT, SAKURACLOUD_TTL")
 	fmt.Fprintln(w, "\tstackpath:\tSTACKPATH_POLLING_INTERVAL, STACKPATH_PROPAGATION_TIMEOUT, STACKPATH_TTL")
+	fmt.Fprintln(w, "\tselectel:\tSELECTEL_BASE_URL, SELECTEL_TTL, SELECTEL_PROPAGATION_TIMEOUT, SELECTEL_POLLING_INTERVAL, SELECTEL_HTTP_TIMEOUT")
 	fmt.Fprintln(w, "\tvegadns:\tVEGADNS_POLLING_INTERVAL, VEGADNS_PROPAGATION_TIMEOUT, VEGADNS_TTL")
 	fmt.Fprintln(w, "\tvultr:\tVULTR_POLLING_INTERVAL, VULTR_PROPAGATION_TIMEOUT, VULTR_TTL, VULTR_HTTP_TIMEOUT")
 
diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go
index 3162fff3..1b9842c9 100644
--- a/providers/dns/dns_providers.go
+++ b/providers/dns/dns_providers.go
@@ -44,6 +44,7 @@ import (
 	"github.com/xenolf/lego/providers/dns/rfc2136"
 	"github.com/xenolf/lego/providers/dns/route53"
 	"github.com/xenolf/lego/providers/dns/sakuracloud"
+	"github.com/xenolf/lego/providers/dns/selectel"
 	"github.com/xenolf/lego/providers/dns/stackpath"
 	"github.com/xenolf/lego/providers/dns/vegadns"
 	"github.com/xenolf/lego/providers/dns/vultr"
@@ -136,6 +137,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error)
 		return sakuracloud.NewDNSProvider()
 	case "stackpath":
 		return stackpath.NewDNSProvider()
+	case "selectel":
+		return selectel.NewDNSProvider()
 	case "vegadns":
 		return vegadns.NewDNSProvider()
 	case "vultr":
diff --git a/providers/dns/selectel/client.go b/providers/dns/selectel/client.go
new file mode 100644
index 00000000..5a52be47
--- /dev/null
+++ b/providers/dns/selectel/client.go
@@ -0,0 +1,206 @@
+package selectel
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+)
+
+// Domain represents domain name.
+type Domain struct {
+	ID   int    `json:"id,omitempty"`
+	Name string `json:"name,omitempty"`
+}
+
+// Record represents DNS record.
+type Record struct {
+	ID      int    `json:"id,omitempty"`
+	Name    string `json:"name,omitempty"`
+	Type    string `json:"type,omitempty"` // Record type (SOA, NS, A/AAAA, CNAME, SRV, MX, TXT, SPF)
+	TTL     int    `json:"ttl,omitempty"`
+	Email   string `json:"email,omitempty"`   // Email of domain's admin (only for SOA records)
+	Content string `json:"content,omitempty"` // Record content (not for SRV)
+}
+
+// Client represents DNS client.
+type Client struct {
+	baseURL    string
+	token      string
+	userAgent  string
+	httpClient *http.Client
+}
+
+// ClientOpts represents options to init client.
+type ClientOpts struct {
+	BaseURL    string
+	Token      string
+	UserAgent  string
+	HTTPClient *http.Client
+}
+
+// APIError API error message
+type APIError struct {
+	Description string `json:"error"`
+	Code        int    `json:"code"`
+	Field       string `json:"field"`
+}
+
+func (a *APIError) Error() string {
+	return fmt.Sprintf("API error: %d - %s - %s", a.Code, a.Description, a.Field)
+}
+
+// NewClient returns a client instance.
+func NewClient(opts ClientOpts) *Client {
+	if opts.HTTPClient == nil {
+		opts.HTTPClient = &http.Client{}
+	}
+
+	return &Client{
+		token:      opts.Token,
+		baseURL:    opts.BaseURL,
+		httpClient: opts.HTTPClient,
+		userAgent:  opts.UserAgent,
+	}
+}
+
+// GetDomainByName gets Domain object by its name.
+func (c *Client) GetDomainByName(domainName string) (*Domain, error) {
+	uri := fmt.Sprintf("/%s", domainName)
+	req, err := c.newRequest(http.MethodGet, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	domain := &Domain{}
+	_, err = c.do(req, domain)
+	if err != nil {
+		return nil, err
+	}
+
+	return domain, nil
+}
+
+// AddRecord adds Record for given domain.
+func (c *Client) AddRecord(domainID int, body Record) (*Record, error) {
+	uri := fmt.Sprintf("/%d/records/", domainID)
+	req, err := c.newRequest(http.MethodPost, uri, body)
+	if err != nil {
+		return nil, err
+	}
+
+	record := &Record{}
+	_, err = c.do(req, record)
+	if err != nil {
+		return nil, err
+	}
+
+	return record, nil
+}
+
+// ListRecords returns list records for specific domain.
+func (c *Client) ListRecords(domainID int) ([]*Record, error) {
+	uri := fmt.Sprintf("/%d/records/", domainID)
+	req, err := c.newRequest(http.MethodGet, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var records []*Record
+	_, err = c.do(req, &records)
+	if err != nil {
+		return nil, err
+	}
+	return records, nil
+}
+
+// DeleteRecord deletes specific record.
+func (c *Client) DeleteRecord(domainID, recordID int) error {
+	uri := fmt.Sprintf("/%d/records/%d", domainID, recordID)
+	req, err := c.newRequest(http.MethodDelete, uri, nil)
+	if err != nil {
+		return err
+	}
+
+	_, err = c.do(req, nil)
+	return err
+}
+
+func (c *Client) newRequest(method, uri string, body interface{}) (*http.Request, error) {
+	buf := new(bytes.Buffer)
+
+	if body != nil {
+		err := json.NewEncoder(buf).Encode(body)
+		if err != nil {
+			return nil, fmt.Errorf("failed to encode request body with error: %v", err)
+		}
+	}
+
+	req, err := http.NewRequest(method, c.baseURL+uri, buf)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create new http request with error: %v", err)
+	}
+
+	req.Header.Add("X-Token", c.token)
+	req.Header.Add("Content-Type", "application/json")
+	req.Header.Add("Accept", "application/json")
+
+	return req, nil
+}
+
+func (c *Client) do(req *http.Request, to interface{}) (*http.Response, error) {
+	resp, err := c.httpClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("request failed with error: %v", err)
+	}
+
+	err = checkResponse(resp)
+	if err != nil {
+		return resp, err
+	}
+
+	if to != nil {
+		if err = extractResult(resp, to); err != nil {
+			return resp, fmt.Errorf("failed to extract request body into interface with error: %v", err)
+		}
+	}
+
+	return resp, nil
+}
+
+func checkResponse(resp *http.Response) error {
+	if resp.StatusCode >= http.StatusBadRequest &&
+		resp.StatusCode <= http.StatusNetworkAuthenticationRequired {
+
+		if resp.Body == nil {
+			return fmt.Errorf("request failed with status code %d and empty body", resp.StatusCode)
+		}
+
+		body, err := ioutil.ReadAll(resp.Body)
+		if err != nil {
+			return err
+		}
+		defer resp.Body.Close()
+
+		apiError := APIError{}
+		err = json.Unmarshal(body, &apiError)
+		if err != nil {
+			return fmt.Errorf("request failed with status code %d, response body: %s", resp.StatusCode, string(body))
+		}
+
+		return fmt.Errorf("request failed with status code %d: %v", resp.StatusCode, apiError)
+	}
+
+	return nil
+}
+
+func extractResult(resp *http.Response, to interface{}) error {
+	body, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	return json.Unmarshal(body, to)
+}
diff --git a/providers/dns/selectel/selectel.go b/providers/dns/selectel/selectel.go
new file mode 100644
index 00000000..9e48b85d
--- /dev/null
+++ b/providers/dns/selectel/selectel.go
@@ -0,0 +1,153 @@
+// Package selectel implements a DNS provider for solving the DNS-01 challenge using Selectel Domains API.
+// Selectel Domain API reference: https://kb.selectel.com/23136054.html
+// Token: https://my.selectel.ru/profile/apikeys
+package selectel
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/xenolf/lego/acme"
+	"github.com/xenolf/lego/platform/config/env"
+)
+
+const (
+	defaultBaseURL = "https://api.selectel.ru/domains/v1"
+	minTTL         = 60
+)
+
+const (
+	envNamespace             = "SELECTEL_"
+	baseURLEnvVar            = envNamespace + "BASE_URL"
+	apiTokenEnvVar           = envNamespace + "API_TOKEN"
+	ttlEnvVar                = envNamespace + "TTL"
+	propagationTimeoutEnvVar = envNamespace + "PROPAGATION_TIMEOUT"
+	pollingIntervalEnvVar    = envNamespace + "POLLING_INTERVAL"
+	httpTimeoutEnvVar        = envNamespace + "HTTP_TIMEOUT"
+)
+
+// Config is used to configure the creation of the DNSProvider.
+type Config struct {
+	BaseURL            string
+	Token              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:            env.GetOrDefaultString(baseURLEnvVar, defaultBaseURL),
+		TTL:                env.GetOrDefaultInt(ttlEnvVar, minTTL),
+		PropagationTimeout: env.GetOrDefaultSecond(propagationTimeoutEnvVar, 120*time.Second),
+		PollingInterval:    env.GetOrDefaultSecond(pollingIntervalEnvVar, 2*time.Second),
+		HTTPClient: &http.Client{
+			Timeout: env.GetOrDefaultSecond(httpTimeoutEnvVar, 30*time.Second),
+		},
+	}
+}
+
+// DNSProvider is an implementation of the acme.ChallengeProvider interface.
+type DNSProvider struct {
+	config *Config
+	client *Client
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for Selectel Domains API.
+// API token must be passed in the environment variable SELECTEL_API_TOKEN.
+func NewDNSProvider() (*DNSProvider, error) {
+	values, err := env.Get(apiTokenEnvVar)
+	if err != nil {
+		return nil, fmt.Errorf("selectel: %v", err)
+	}
+
+	config := NewDefaultConfig()
+	config.Token = values[apiTokenEnvVar]
+
+	return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for selectel.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+	if config == nil {
+		return nil, errors.New("selectel: the configuration of the DNS provider is nil")
+	}
+
+	if config.Token == "" {
+		return nil, errors.New("selectel: credentials missing")
+	}
+
+	if config.TTL < minTTL {
+		return nil, fmt.Errorf("selectel: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
+	}
+
+	client := NewClient(ClientOpts{
+		BaseURL:    config.BaseURL,
+		Token:      config.Token,
+		UserAgent:  acme.UserAgent,
+		HTTPClient: config.HTTPClient,
+	})
+
+	return &DNSProvider{config: config, client: client}, nil
+}
+
+// Timeout returns the Timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+	return d.config.PropagationTimeout, d.config.PollingInterval
+}
+
+// Present creates a TXT record to fulfill DNS-01 challenge.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+	fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
+
+	domainObj, err := d.client.GetDomainByName(domain)
+	if err != nil {
+		return fmt.Errorf("selectel: %v", err)
+	}
+
+	txtRecord := Record{
+		Type:    "TXT",
+		TTL:     d.config.TTL,
+		Name:    fqdn,
+		Content: value,
+	}
+	_, err = d.client.AddRecord(domainObj.ID, txtRecord)
+	if err != nil {
+		return fmt.Errorf("selectel: %v", err)
+	}
+
+	return nil
+}
+
+// CleanUp removes a TXT record used for DNS-01 challenge.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+	fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
+
+	domainObj, err := d.client.GetDomainByName(domain)
+	if err != nil {
+		return fmt.Errorf("selectel: %v", err)
+	}
+
+	records, err := d.client.ListRecords(domainObj.ID)
+	if err != nil {
+		return fmt.Errorf("selectel: %v", err)
+	}
+
+	// Delete records with specific FQDN
+	var lastErr error
+	for _, record := range records {
+		if record.Name == fqdn {
+			err = d.client.DeleteRecord(domainObj.ID, record.ID)
+			if err != nil {
+				lastErr = fmt.Errorf("selectel: %v", err)
+			}
+		}
+	}
+
+	return lastErr
+}


Test patch:
diff --git a/providers/dns/selectel/selectel_test.go b/providers/dns/selectel/selectel_test.go
new file mode 100644
index 00000000..8b7570e8
--- /dev/null
+++ b/providers/dns/selectel/selectel_test.go
@@ -0,0 +1,129 @@
+package selectel
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/xenolf/lego/platform/tester"
+)
+
+var envTest = tester.NewEnvTest(apiTokenEnvVar, ttlEnvVar)
+
+func TestNewDNSProvider(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		envVars  map[string]string
+		expected string
+	}{
+		{
+			desc: "success",
+			envVars: map[string]string{
+				apiTokenEnvVar: "123",
+			},
+		},
+		{
+			desc: "missing api key",
+			envVars: map[string]string{
+				apiTokenEnvVar: "",
+			},
+			expected: fmt.Sprintf("selectel: some credentials information are missing: %s", apiTokenEnvVar),
+		},
+	}
+
+	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)
+				assert.NotNil(t, p.config)
+				assert.NotNil(t, p.client)
+			} else {
+				require.EqualError(t, err, test.expected)
+			}
+		})
+	}
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		token    string
+		ttl      int
+		expected string
+	}{
+		{
+			desc:  "success",
+			token: "123",
+			ttl:   60,
+		},
+		{
+			desc:     "missing api key",
+			token:    "",
+			ttl:      60,
+			expected: "selectel: credentials missing",
+		},
+		{
+			desc:     "bad TTL value",
+			token:    "123",
+			ttl:      59,
+			expected: fmt.Sprintf("selectel: invalid TTL, TTL (59) must be greater than %d", minTTL),
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			config := NewDefaultConfig()
+			config.TTL = test.ttl
+			config.Token = test.token
+
+			p, err := NewDNSProviderConfig(config)
+
+			if len(test.expected) == 0 {
+				require.NoError(t, err)
+				require.NotNil(t, p)
+				assert.NotNil(t, p.config)
+				assert.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)
+}