add ConoHa DNS provider (#692)

This commit is contained in:
Kazuki Sawada 2018-11-02 10:30:09 +09:00 committed by Ludovic Fernandez
parent 2b0aa0aadf
commit 8556397ec0
7 changed files with 735 additions and 0 deletions

View file

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

2
cli.go
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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":