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/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.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/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) +} 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":