Repository: xenolf/lego
Pull request #692 (2018-10-30 13:03:23)

add ConoHa DNS provider
I added DNS provider for [ConoHa DNS](https://www.conoha.jp/docs/).

# Live test results

```sh
$ go test github.com/xenolf/lego/providers/dns/conoha --cover -v
=== RUN   TestNewDNSProvider
=== RUN   TestNewDNSProvider/complete_credentials,_but_login_failed
=== RUN   TestNewDNSProvider/missing_credentials
=== RUN   TestNewDNSProvider/missing_tenant_id
=== RUN   TestNewDNSProvider/missing_api_username
=== RUN   TestNewDNSProvider/missing_api_password
--- PASS: TestNewDNSProvider (0.37s)
    --- PASS: TestNewDNSProvider/complete_credentials,_but_login_failed (0.36s)
    --- PASS: TestNewDNSProvider/missing_credentials (0.00s)
    --- PASS: TestNewDNSProvider/missing_tenant_id (0.00s)
    --- PASS: TestNewDNSProvider/missing_api_username (0.00s)
    --- PASS: TestNewDNSProvider/missing_api_password (0.00s)
=== RUN   TestNewDNSProviderConfig
=== RUN   TestNewDNSProviderConfig/complete_credentials,_but_login_failed
=== RUN   TestNewDNSProviderConfig/missing_credentials
=== RUN   TestNewDNSProviderConfig/missing_tenant_id
=== RUN   TestNewDNSProviderConfig/missing_api_username
=== RUN   TestNewDNSProviderConfig/missing_api_password
--- PASS: TestNewDNSProviderConfig (0.46s)
    --- PASS: TestNewDNSProviderConfig/complete_credentials,_but_login_failed (0.46s)
    --- PASS: TestNewDNSProviderConfig/missing_credentials (0.00s)
    --- PASS: TestNewDNSProviderConfig/missing_tenant_id (0.00s)
    --- PASS: TestNewDNSProviderConfig/missing_api_username (0.00s)
    --- PASS: TestNewDNSProviderConfig/missing_api_password (0.00s)
=== RUN   TestLivePresent
--- PASS: TestLivePresent (0.92s)
=== RUN   TestLiveCleanUp
--- PASS: TestLiveCleanUp (2.15s)
PASS
coverage: 83.0% of statements
ok  	github.com/xenolf/lego/providers/dns/conoha	3.913s	coverage: 83.0% of statements
```

# Obtaining a wildcard certificate

```sh
$ ./lego --email kazuki@6715.jp --dns conoha --domains *.6715.jp --server https://acme-staging-v02.api.letsencrypt.org/directory run
2018/10/30 21:53:40 [INFO] [*.6715.jp] acme: Obtaining bundled SAN certificate
2018/10/30 21:53:41 [INFO] [*.6715.jp] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz/U8yU-U4GdGcMPcNNYLbwFcPX-QGcMd-J6Gyed5qDJVw
2018/10/30 21:53:41 [INFO] [6715.jp] acme: Preparing to solve DNS-01
2018/10/30 21:53:42 [INFO] [6715.jp] acme: Trying to solve DNS-01
2018/10/30 21:53:42 [INFO] [6715.jp] Checking DNS record propagation using [10.0.3.33:53 10.0.3.34:53]
2018/10/30 21:53:42 [INFO] Wait [timeout: 1m0s, interval: 2s]
2018/10/30 21:53:47 [INFO] [6715.jp] The server validated our request
2018/10/30 21:53:48 [INFO] [*.6715.jp] acme: Validations succeeded; requesting certificates
2018/10/30 21:53:51 [INFO] [*.6715.jp] Server responded with a certificate.
```



Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8455530d..9b574af7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -42,6 +42,7 @@ owners to license your work under the terms of the [MIT License](LICENSE).
 | Bluecat                   | `bluecat`      | ?                                                                                                            | -                                                                 |
 | Cloudflare                | `cloudflare`   | [documentation](https://api.cloudflare.com/)                                                                 | [Go client](https://github.com/cloudflare/cloudflare-go)          |
 | CloudXNS                  | `cloudxns`     | [documentation](https://www.cloudxns.net/Public/Doc/CloudXNS_api2.0_doc_zh-cn.zip)                           | -                                                                 |
+| ConoHa                    | `conoha`       | [documentation](https://www.conoha.jp/docs/)                                                                 | -                                                                 |
 | Digital Ocean             | `digitalocean` | [documentation](https://developers.digitalocean.com/documentation/v2/#domain-records)                        | -                                                                 |
 | DNSimple                  | `dnsimple`     | [documentation](https://developer.dnsimple.com/v2/)                                                          | [Go client](https://github.com/dnsimple/dnsimple-go)              |
 | DNS Made Easy             | `dnsmadeeasy`  | [documentation](https://api-docs.dnsmadeeasy.com/)                                                           | -                                                                 |
diff --git a/cli.go b/cli.go
index 8f8356a0..6f553913 100644
--- a/cli.go
+++ b/cli.go
@@ -209,6 +209,7 @@ Here is an example bash command using the CloudFlare DNS provider:
 	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, "\tcloudxns:\tCLOUDXNS_API_KEY, CLOUDXNS_SECRET_KEY")
+	fmt.Fprintln(w, "\tconoha:\tCONOHA_REGION, CONOHA_TENANT_ID, CONOHA_API_USERNAME, CONOHA_API_PASSWORD")
 	fmt.Fprintln(w, "\tdigitalocean:\tDO_AUTH_TOKEN")
 	fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_OAUTH_TOKEN")
 	fmt.Fprintln(w, "\tdnsmadeeasy:\tDNSMADEEASY_API_KEY, DNSMADEEASY_API_SECRET")
@@ -254,6 +255,7 @@ Here is an example bash command using the CloudFlare DNS provider:
 	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, "\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")
 	fmt.Fprintln(w, "\tdigitalocean:\tDO_POLLING_INTERVAL, DO_PROPAGATION_TIMEOUT, DO_TTL, DO_HTTP_TIMEOUT")
 	fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_TTL, DNSIMPLE_POLLING_INTERVAL, DNSIMPLE_PROPAGATION_TIMEOUT")
 	fmt.Fprintln(w, "\tdnsmadeeasy:\tDNSMADEEASY_POLLING_INTERVAL, DNSMADEEASY_PROPAGATION_TIMEOUT, DNSMADEEASY_TTL, DNSMADEEASY_HTTP_TIMEOUT")
diff --git a/providers/dns/conoha/client.go b/providers/dns/conoha/client.go
new file mode 100644
index 00000000..3fa8b5bb
--- /dev/null
+++ b/providers/dns/conoha/client.go
@@ -0,0 +1,205 @@
+package conoha
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+)
+
+const (
+	identityBaseURL   = "https://identity.%s.conoha.io"
+	dnsServiceBaseURL = "https://dns-service.%s.conoha.io"
+)
+
+// IdentityRequest is an authentication request body.
+type IdentityRequest struct {
+	Auth Auth `json:"auth"`
+}
+
+// Auth is an authentication information.
+type Auth struct {
+	TenantID            string              `json:"tenantId"`
+	PasswordCredentials PasswordCredentials `json:"passwordCredentials"`
+}
+
+// PasswordCredentials is API-user's credentials.
+type PasswordCredentials struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+}
+
+// IdentityResponse is an authentication response body.
+type IdentityResponse struct {
+	Access Access `json:"access"`
+}
+
+// Access is an identity information.
+type Access struct {
+	Token Token `json:"token"`
+}
+
+// Token is an api access token.
+type Token struct {
+	ID string `json:"id"`
+}
+
+// DomainListResponse is a response of a domain listing request.
+type DomainListResponse struct {
+	Domains []Domain `json:"domains"`
+}
+
+// Domain is a hosted domain entry.
+type Domain struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+}
+
+// RecordListResponse is a response of record listing request.
+type RecordListResponse struct {
+	Records []Record `json:"records"`
+}
+
+// Record is a record entry.
+type Record struct {
+	ID   string `json:"id,omitempty"`
+	Name string `json:"name"`
+	Type string `json:"type"`
+	Data string `json:"data"`
+	TTL  int    `json:"ttl"`
+}
+
+// Client is a ConoHa API client.
+type Client struct {
+	token      string
+	endpoint   string
+	httpClient *http.Client
+}
+
+// NewClient returns a client instance logged into the ConoHa service.
+func NewClient(region string, auth Auth, httpClient *http.Client) (*Client, error) {
+	if httpClient == nil {
+		httpClient = &http.Client{}
+	}
+
+	c := &Client{httpClient: httpClient}
+
+	c.endpoint = fmt.Sprintf(identityBaseURL, region)
+
+	identity, err := c.getIdentity(auth)
+	if err != nil {
+		return nil, fmt.Errorf("failed to login: %v", err)
+	}
+
+	c.token = identity.Access.Token.ID
+	c.endpoint = fmt.Sprintf(dnsServiceBaseURL, region)
+
+	return c, nil
+}
+
+func (c *Client) getIdentity(auth Auth) (*IdentityResponse, error) {
+	req := &IdentityRequest{Auth: auth}
+
+	identity := &IdentityResponse{}
+
+	err := c.do(http.MethodPost, "/v2.0/tokens", req, identity)
+	if err != nil {
+		return nil, err
+	}
+
+	return identity, nil
+}
+
+// GetDomainID returns an ID of specified domain.
+func (c *Client) GetDomainID(domainName string) (string, error) {
+	domainList := &DomainListResponse{}
+
+	err := c.do(http.MethodGet, "/v1/domains", nil, domainList)
+	if err != nil {
+		return "", err
+	}
+
+	for _, domain := range domainList.Domains {
+		if domain.Name == domainName {
+			return domain.ID, nil
+		}
+	}
+	return "", fmt.Errorf("no such domain: %s", domainName)
+}
+
+// GetRecordID returns an ID of specified record.
+func (c *Client) GetRecordID(domainID, recordName, recordType, data string) (string, error) {
+	recordList := &RecordListResponse{}
+
+	err := c.do(http.MethodGet, fmt.Sprintf("/v1/domains/%s/records", domainID), nil, recordList)
+	if err != nil {
+		return "", err
+	}
+
+	for _, record := range recordList.Records {
+		if record.Name == recordName && record.Type == recordType && record.Data == data {
+			return record.ID, nil
+		}
+	}
+	return "", errors.New("no such record")
+}
+
+// CreateRecord adds new record.
+func (c *Client) CreateRecord(domainID string, record Record) error {
+	return c.do(http.MethodPost, fmt.Sprintf("/v1/domains/%s/records", domainID), record, nil)
+}
+
+// DeleteRecord removes specified record.
+func (c *Client) DeleteRecord(domainID, recordID string) error {
+	return c.do(http.MethodDelete, fmt.Sprintf("/v1/domains/%s/records/%s", domainID, recordID), nil, nil)
+}
+
+func (c *Client) do(method, path string, payload, result interface{}) error {
+	body := bytes.NewReader(nil)
+
+	if payload != nil {
+		bodyBytes, err := json.Marshal(payload)
+		if err != nil {
+			return err
+		}
+		body = bytes.NewReader(bodyBytes)
+	}
+
+	req, err := http.NewRequest(method, c.endpoint+path, body)
+	if err != nil {
+		return err
+	}
+
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("X-Auth-Token", c.token)
+
+	resp, err := c.httpClient.Do(req)
+	if err != nil {
+		return err
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		respBody, err := ioutil.ReadAll(resp.Body)
+		if err != nil {
+			return err
+		}
+		defer resp.Body.Close()
+
+		return fmt.Errorf("HTTP request failed with status code %d: %s", resp.StatusCode, string(respBody))
+	}
+
+	if result != nil {
+		respBody, err := ioutil.ReadAll(resp.Body)
+		if err != nil {
+			return err
+		}
+		defer resp.Body.Close()
+
+		return json.Unmarshal(respBody, result)
+	}
+
+	return nil
+}
diff --git a/providers/dns/conoha/conoha.go b/providers/dns/conoha/conoha.go
new file mode 100644
index 00000000..969a9bd7
--- /dev/null
+++ b/providers/dns/conoha/conoha.go
@@ -0,0 +1,138 @@
+// Package conoha implements a DNS provider for solving the DNS-01 challenge
+// using ConoHa DNS.
+package conoha
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/xenolf/lego/acme"
+	"github.com/xenolf/lego/platform/config/env"
+)
+
+// Config is used to configure the creation of the DNSProvider
+type Config struct {
+	Region             string
+	TenantID           string
+	Username           string
+	Password           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{
+		Region:             env.GetOrDefaultString("CONOHA_REGION", "tyo1"),
+		TTL:                env.GetOrDefaultInt("CONOHA_TTL", 60),
+		PropagationTimeout: env.GetOrDefaultSecond("CONOHA_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout),
+		PollingInterval:    env.GetOrDefaultSecond("CONOHA_POLLING_INTERVAL", acme.DefaultPollingInterval),
+		HTTPClient: &http.Client{
+			Timeout: env.GetOrDefaultSecond("CONOHA_HTTP_TIMEOUT", 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 ConoHa DNS.
+// Credentials must be passed in the environment variables: CONOHA_TENANT_ID, CONOHA_API_USERNAME, CONOHA_API_PASSWORD
+func NewDNSProvider() (*DNSProvider, error) {
+	values, err := env.Get("CONOHA_TENANT_ID", "CONOHA_API_USERNAME", "CONOHA_API_PASSWORD")
+	if err != nil {
+		return nil, fmt.Errorf("conoha: %v", err)
+	}
+
+	config := NewDefaultConfig()
+	config.TenantID = values["CONOHA_TENANT_ID"]
+	config.Username = values["CONOHA_API_USERNAME"]
+	config.Password = values["CONOHA_API_PASSWORD"]
+
+	return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for ConoHa DNS.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+	if config == nil {
+		return nil, errors.New("conoha: the configuration of the DNS provider is nil")
+	}
+
+	if config.TenantID == "" || config.Username == "" || config.Password == "" {
+		return nil, errors.New("conoha: some credentials information are missing")
+	}
+
+	auth := Auth{
+		TenantID: config.TenantID,
+		PasswordCredentials: PasswordCredentials{
+			Username: config.Username,
+			Password: config.Password,
+		},
+	}
+
+	client, err := NewClient(config.Region, auth, config.HTTPClient)
+	if err != nil {
+		return nil, fmt.Errorf("conoha: failed to create client: %v", err)
+	}
+
+	return &DNSProvider{config: config, client: client}, nil
+}
+
+// Present creates a TXT record to fulfill the dns-01 challenge.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+	fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
+
+	id, err := d.client.GetDomainID(acme.ToFqdn(domain))
+	if err != nil {
+		return fmt.Errorf("conoha: failed to get domain ID: %v", err)
+	}
+
+	record := Record{
+		Name: fqdn,
+		Type: "TXT",
+		Data: value,
+		TTL:  d.config.TTL,
+	}
+
+	err = d.client.CreateRecord(id, record)
+	if err != nil {
+		return fmt.Errorf("conoha: failed to create record: %v", err)
+	}
+
+	return nil
+}
+
+// CleanUp clears ConoHa DNS TXT record
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+	fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
+
+	domID, err := d.client.GetDomainID(acme.ToFqdn(domain))
+	if err != nil {
+		return fmt.Errorf("conoha: failed to get domain ID: %v", err)
+	}
+
+	recID, err := d.client.GetRecordID(domID, fqdn, "TXT", value)
+	if err != nil {
+		return fmt.Errorf("conoha: failed to get record ID: %v", err)
+	}
+
+	err = d.client.DeleteRecord(domID, recID)
+	if err != nil {
+		return fmt.Errorf("conoha: failed to delete record: %v", err)
+	}
+
+	return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+	return d.config.PropagationTimeout, d.config.PollingInterval
+}
diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go
index ff7a7d83..3162fff3 100644
--- a/providers/dns/dns_providers.go
+++ b/providers/dns/dns_providers.go
@@ -11,6 +11,7 @@ import (
 	"github.com/xenolf/lego/providers/dns/bluecat"
 	"github.com/xenolf/lego/providers/dns/cloudflare"
 	"github.com/xenolf/lego/providers/dns/cloudxns"
+	"github.com/xenolf/lego/providers/dns/conoha"
 	"github.com/xenolf/lego/providers/dns/digitalocean"
 	"github.com/xenolf/lego/providers/dns/dnsimple"
 	"github.com/xenolf/lego/providers/dns/dnsmadeeasy"
@@ -65,6 +66,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error)
 		return cloudflare.NewDNSProvider()
 	case "cloudxns":
 		return cloudxns.NewDNSProvider()
+	case "conoha":
+		return conoha.NewDNSProvider()
 	case "digitalocean":
 		return digitalocean.NewDNSProvider()
 	case "dnsimple":


Test patch:
diff --git a/providers/dns/conoha/client_test.go b/providers/dns/conoha/client_test.go
new file mode 100644
index 00000000..2db899ae
--- /dev/null
+++ b/providers/dns/conoha/client_test.go
@@ -0,0 +1,210 @@
+package conoha
+
+import (
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func setupClientTest() (*http.ServeMux, *Client, func()) {
+	mux := http.NewServeMux()
+	server := httptest.NewServer(mux)
+
+	client := &Client{
+		token:      "secret",
+		endpoint:   server.URL,
+		httpClient: &http.Client{Timeout: 5 * time.Second},
+	}
+
+	return mux, client, server.Close
+}
+
+func TestClient_GetDomainID(t *testing.T) {
+	type expected struct {
+		domainID string
+		error    bool
+	}
+
+	testCases := []struct {
+		desc       string
+		domainName string
+		handler    http.HandlerFunc
+		expected   expected
+	}{
+		{
+			desc:       "success",
+			domainName: "domain1.com.",
+			handler: func(rw http.ResponseWriter, req *http.Request) {
+				if req.Method != http.MethodGet {
+					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed)
+					return
+				}
+
+				content := `
+{
+    "domains":[
+      {
+        "id": "09494b72-b65b-4297-9efb-187f65a0553e",
+        "name": "domain1.com.",
+        "ttl": 3600,
+        "serial": 1351800668,
+        "email": "nsadmin@example.org",
+        "gslb": 0,
+        "created_at": "2012-11-01T20:11:08.000000",
+        "updated_at": null,
+        "description": "memo"
+      },
+      {
+        "id": "cf661142-e577-40b5-b3eb-75795cdc0cd7",
+        "name": "domain2.com.",
+        "ttl": 7200,
+        "serial": 1351800670,
+        "email": "nsadmin2@example.org",
+        "gslb": 1,
+        "created_at": "2012-11-01T20:11:08.000000",
+        "updated_at": "2012-12-01T20:11:08.000000",
+        "description": "memomemo"
+      }
+    ]
+}
+`
+				_, err := fmt.Fprint(rw, content)
+				if err != nil {
+					http.Error(rw, err.Error(), http.StatusInternalServerError)
+					return
+				}
+			},
+			expected: expected{domainID: "09494b72-b65b-4297-9efb-187f65a0553e"},
+		},
+		{
+			desc:       "non existing domain",
+			domainName: "domain1.com.",
+			handler: func(rw http.ResponseWriter, req *http.Request) {
+				if req.Method != http.MethodGet {
+					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed)
+					return
+				}
+
+				_, err := fmt.Fprint(rw, "{}")
+				if err != nil {
+					http.Error(rw, err.Error(), http.StatusInternalServerError)
+					return
+				}
+			},
+			expected: expected{error: true},
+		},
+		{
+			desc:       "marshaling error",
+			domainName: "domain1.com.",
+			handler: func(rw http.ResponseWriter, req *http.Request) {
+				if req.Method != http.MethodGet {
+					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed)
+					return
+				}
+
+				_, err := fmt.Fprint(rw, "[]")
+				if err != nil {
+					http.Error(rw, err.Error(), http.StatusInternalServerError)
+					return
+				}
+			},
+			expected: expected{error: true},
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			mux, client, tearDown := setupClientTest()
+			defer tearDown()
+
+			mux.Handle("/v1/domains", test.handler)
+
+			domainID, err := client.GetDomainID(test.domainName)
+
+			if test.expected.error {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				assert.Equal(t, test.expected.domainID, domainID)
+			}
+		})
+	}
+
+}
+
+func TestClient_CreateRecord(t *testing.T) {
+	testCases := []struct {
+		desc        string
+		handler     http.HandlerFunc
+		expectError bool
+	}{
+		{
+			desc: "success",
+			handler: func(rw http.ResponseWriter, req *http.Request) {
+				if req.Method != http.MethodPost {
+					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed)
+					return
+				}
+
+				raw, err := ioutil.ReadAll(req.Body)
+				if err != nil {
+					http.Error(rw, err.Error(), http.StatusBadRequest)
+					return
+				}
+				defer req.Body.Close()
+
+				if string(raw) != `{"name":"lego.com.","type":"TXT","data":"txtTXTtxt","ttl":300}` {
+					http.Error(rw, fmt.Sprintf("invalid request body: %s", string(raw)), http.StatusBadRequest)
+					return
+				}
+			},
+		},
+		{
+			desc: "bad request",
+			handler: func(rw http.ResponseWriter, req *http.Request) {
+				if req.Method != http.MethodPost {
+					http.Error(rw, fmt.Sprintf("%s: %s", http.StatusText(http.StatusMethodNotAllowed), req.Method), http.StatusMethodNotAllowed)
+					return
+				}
+
+				http.Error(rw, "OOPS", http.StatusBadRequest)
+			},
+			expectError: true,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			mux, client, tearDown := setupClientTest()
+			defer tearDown()
+
+			mux.Handle("/v1/domains/lego/records", test.handler)
+
+			domainID := "lego"
+
+			record := Record{
+				Name: "lego.com.",
+				Type: "TXT",
+				Data: "txtTXTtxt",
+				TTL:  300,
+			}
+
+			err := client.CreateRecord(domainID, record)
+
+			if test.expectError {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+			}
+
+		})
+	}
+
+}
diff --git a/providers/dns/conoha/conoha_test.go b/providers/dns/conoha/conoha_test.go
new file mode 100644
index 00000000..bbc0dea9
--- /dev/null
+++ b/providers/dns/conoha/conoha_test.go
@@ -0,0 +1,176 @@
+package conoha
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+	"github.com/xenolf/lego/platform/tester"
+)
+
+var envTest = tester.NewEnvTest(
+	"CONOHA_TENANT_ID",
+	"CONOHA_API_USERNAME",
+	"CONOHA_API_PASSWORD").
+	WithDomain("CONOHA_DOMAIN")
+
+func TestNewDNSProvider(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		envVars  map[string]string
+		expected string
+	}{
+		{
+			desc: "complete credentials, but login failed",
+			envVars: map[string]string{
+				"CONOHA_TENANT_ID":    "tenant_id",
+				"CONOHA_API_USERNAME": "api_username",
+				"CONOHA_API_PASSWORD": "api_password",
+			},
+			expected: `conoha: failed to create client: failed to login: HTTP request failed with status code 401: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`,
+		},
+		{
+			desc: "missing credentials",
+			envVars: map[string]string{
+				"CONOHA_TENANT_ID":    "",
+				"CONOHA_API_USERNAME": "",
+				"CONOHA_API_PASSWORD": "",
+			},
+			expected: "conoha: some credentials information are missing: CONOHA_TENANT_ID,CONOHA_API_USERNAME,CONOHA_API_PASSWORD",
+		},
+		{
+			desc: "missing tenant id",
+			envVars: map[string]string{
+				"CONOHA_TENANT_ID":    "",
+				"CONOHA_API_USERNAME": "api_username",
+				"CONOHA_API_PASSWORD": "api_password",
+			},
+			expected: "conoha: some credentials information are missing: CONOHA_TENANT_ID",
+		},
+		{
+			desc: "missing api username",
+			envVars: map[string]string{
+				"CONOHA_TENANT_ID":    "tenant_id",
+				"CONOHA_API_USERNAME": "",
+				"CONOHA_API_PASSWORD": "api_password",
+			},
+			expected: "conoha: some credentials information are missing: CONOHA_API_USERNAME",
+		},
+		{
+			desc: "missing api password",
+			envVars: map[string]string{
+				"CONOHA_TENANT_ID":    "tenant_id",
+				"CONOHA_API_USERNAME": "api_username",
+				"CONOHA_API_PASSWORD": "",
+			},
+			expected: "conoha: some credentials information are missing: CONOHA_API_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)
+			} else {
+				require.EqualError(t, err, test.expected)
+			}
+		})
+	}
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		expected string
+		tenant   string
+		username string
+		password string
+	}{
+		{
+			desc:     "complete credentials, but login failed",
+			expected: `conoha: failed to create client: failed to login: HTTP request failed with status code 401: {"unauthorized":{"message":"Invalid user: api_username","code":401}}`,
+			tenant:   "tenant_id",
+			username: "api_username",
+			password: "api_password",
+		},
+		{
+			desc:     "missing credentials",
+			expected: "conoha: some credentials information are missing",
+		},
+		{
+			desc:     "missing tenant id",
+			expected: "conoha: some credentials information are missing",
+			username: "api_username",
+			password: "api_password",
+		},
+		{
+			desc:     "missing api username",
+			expected: "conoha: some credentials information are missing",
+			tenant:   "tenant_id",
+			password: "api_password",
+		},
+		{
+			desc:     "missing api password",
+			expected: "conoha: some credentials information are missing",
+			tenant:   "tenant_id",
+			username: "api_username",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			config := NewDefaultConfig()
+			config.TenantID = test.tenant
+			config.Username = test.username
+			config.Password = test.password
+
+			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(1 * time.Second)
+
+	err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+	require.NoError(t, err)
+}