NEW PROVIDER: ClouDNS (#578)

* ClouDNS: first version of provider
* ClouDNS: documentation
* ClouDNS: code cleanup
* ClouDNS: GetNameservers now uses ClouDNS API to fetch NS servers list
* ClouDNS: CAA support
* ClouDNS: TLSA support
* ClouDNS: tests credentials now use variables instead of hardcoded values
* ClouDNS: SSHFP support
* ClouDNS: export only necessary methods
This commit is contained in:
Anton Yurchenko 2020-01-21 02:07:38 +07:00 committed by Tom Limoncelli
parent 16d0043cce
commit 253cd07154
8 changed files with 557 additions and 0 deletions

1
OWNERS
View file

@ -2,6 +2,7 @@
providers/azuredns @vatsalyagoel
providers/bind @tlimoncelli
# providers/cloudflare
providers/cloudns @pragmaton
providers/digitalocean @Deraen
providers/dnsimple @aeden
providers/gandi @TomOnTime

View file

@ -17,6 +17,7 @@ Currently supported DNS providers:
- Azure DNS
- BIND
- Cloudflare
- ClouDNS
- DigitalOcean
- DNSimple
- Exoscale

View file

@ -0,0 +1,58 @@
---
name: ClouDNS
title: ClouDNS Provider
layout: default
jsId: CLOUDNS
---
# ClouDNS Provider
## Configuration
In your credentials file, you must provide your [Api user ID and password](https://asia.cloudns.net/wiki/article/42/).
Current version of provider doesn't support `sub-auth-id` or `sub-auth-user`.
{% highlight json %}
{
"cloudns": {
"auth-id": "12345",
"auth-password": "your-password"
}
}
{% endhighlight %}
## Metadata
This provider does not recognize any special metadata fields unique to ClouDNS.
## Usage
Example Javascript:
{% highlight js %}
var REG_NONE = NewRegistrar('none', 'NONE')
var CLOUDNS = NewDnsProvider("cloudns", "CLOUDNS");
D("example.tld", REG_NONE, DnsProvider(CLOUDNS),
A("test","1.2.3.4")
);
{%endhighlight%}
## Activation
[Create Auth ID](https://asia.cloudns.net/api-settings/). Only paid account can use API
## Caveats
ClouDNS does not allow all TTLs, but only a specific subset of TTLs. The following [TTLs are supported](https://asia.cloudns.net/wiki/article/188/):
- 60 (1 minute)
- 300 (5 minutes)
- 900 (15 minutes)
- 1800 (30 minutes)
- 3600 (1 hour)
- 21600 (6 hours)
- 43200 (12 hours)
- 86400 (1 day)
- 172800 (2 days)
- 259200 (3 days)
- 604800 (1 week)
- 1209600 (2 weeks)
- 2419200 (4 weeks)
The provider will automatically round up your TTL to one of these values. For example, 350 seconds would become 900
seconds, but 300 seconds would stay 300 seconds.

View file

@ -61,6 +61,7 @@ provided to help community members support their code independently.
Maintainers of contributed providers:
* ClouDNS @pragmaton
* digital ocean @Deraen
* dnsimple @aeden
* gandi @TomOnTime

View file

@ -15,6 +15,13 @@
"BIND": {
"domain": "example.com"
},
"CLOUDNS": {
"auth-id": "$CLOUDNS_AUTH_ID",
"auth-password": "$CLOUDNS_AUTH_PASSWORD",
"domain": "$CLOUDNS_DOMAIN",
"knownFailures": "53"
},
"CLOUDFLAREAPI_OLD": {
"apikey": "$CF_KEY",
"apiuser": "$CF_USER",

View file

@ -7,6 +7,7 @@ import (
_ "github.com/StackExchange/dnscontrol/providers/azuredns"
_ "github.com/StackExchange/dnscontrol/providers/bind"
_ "github.com/StackExchange/dnscontrol/providers/cloudflare"
_ "github.com/StackExchange/dnscontrol/providers/cloudns"
_ "github.com/StackExchange/dnscontrol/providers/digitalocean"
_ "github.com/StackExchange/dnscontrol/providers/dnsimple"
_ "github.com/StackExchange/dnscontrol/providers/exoscale"

235
providers/cloudns/api.go Normal file
View file

@ -0,0 +1,235 @@
package cloudns
import (
"encoding/json"
"github.com/pkg/errors"
"io/ioutil"
"net/http"
"strconv"
)
// Api layer for CloDNS
type api struct {
domainIndex map[string]string
nameserversNames []string
creds struct {
id string
password string
}
}
type requestParams map[string]string
type errorResponse struct {
Status string `json:"status"`
Description string `json:"statusDescription"`
}
type nameserverRecord struct {
Type string `json:"type"`
Name string `json:"name"`
}
type nameserverResponse []nameserverRecord
type zoneRecord struct {
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Zone string `json:"zone"`
}
type zoneResponse []zoneRecord
type domainRecord struct {
ID string `json:"id"`
Type string `json:"type"`
Host string `json:"host"`
Target string `json:"record"`
Priority string `json:"priority"`
Weight string `json:"weight"`
Port string `json:"port"`
Service string `json:"service"`
Protocol string `json:"protocol"`
TTL string `json:"ttl"`
Status int8 `json:"status"`
CaaFlag string `json:"caa_flag,omitempty"`
CaaTag string `json:"caa_type,omitempty"`
CaaValue string `json:"caa_value,omitempty"`
TlsaUsage string `json:"tlsa_usage,omitempty"`
TlsaSelector string `json:"tlsa_selector,omitempty"`
TlsaMatchingType string `json:"tlsa_matching_type,omitempty"`
SshfpAlgorithm string `json:"algorithm,omitempty"`
SshfpFingerprint string `json:"fp_type,omitempty"`
}
type recordResponse map[string]domainRecord
var allowedTTLValues = []uint32{
60, // 1 minute
300, // 5 minutes
900, // 15 minutes
1800, // 30 minutes
3600, // 1 hour
21600, // 6 hours
43200, // 12 hours
86400, // 1 day
172800, // 2 days
259200, // 3 days
604800, // 1 week
1209600, // 2 weeks
2419200, // 4 weeks
}
func (c *api) fetchAvailableNameservers() error {
c.nameserversNames = nil
var bodyString, err = c.get("/dns/available-name-servers.json", requestParams{})
if err != nil {
return errors.Errorf("Error fetching available nameservers list from ClouDNS: %s", err)
}
var nr nameserverResponse
json.Unmarshal(bodyString, &nr)
for _, nameserver := range nr {
if nameserver.Type == "premium" {
c.nameserversNames = append(c.nameserversNames, nameserver.Name)
}
}
return nil
}
func (c *api) fetchDomainList() error {
c.domainIndex = map[string]string{}
rowsPerPage := 100
page := 1
for {
var dr zoneResponse
params := requestParams{
"page": strconv.Itoa(page),
"rows-per-page": strconv.Itoa(rowsPerPage),
}
endpoint := "/dns/list-zones.json"
var bodyString, err = c.get(endpoint, params)
if err != nil {
return errors.Errorf("Error fetching domain list from ClouDNS: %s", err)
}
json.Unmarshal(bodyString, &dr)
for _, domain := range dr {
c.domainIndex[domain.Name] = domain.Name
}
if len(dr) < rowsPerPage {
break
}
page++
}
return nil
}
func (c *api) createDomain(domain string) error {
params := requestParams{
"domain-name": domain,
"zone-type": "master",
}
if _, err := c.get("/dns/register.json", params); err != nil {
return errors.Errorf("Error create domain ClouDNS: %s", err)
}
return nil
}
func (c *api) createRecord(domainID string, rec requestParams) error {
rec["domain-name"] = domainID
if _, err := c.get("/dns/add-record.json", rec); err != nil {
return errors.Errorf("Error create record ClouDNS: %s", err)
}
return nil
}
func (c *api) deleteRecord(domainID string, recordID string) error {
params := requestParams{
"domain-name": domainID,
"record-id": recordID,
}
if _, err := c.get("/dns/delete-record.json", params); err != nil {
return errors.Errorf("Error delete record ClouDNS: %s", err)
}
return nil
}
func (c *api) modifyRecord(domainID string, recordID string, rec requestParams) error {
rec["domain-name"] = domainID
rec["record-id"] = recordID
if _, err := c.get("/dns/mod-record.json", rec); err != nil {
return errors.Errorf("Error create update ClouDNS: %s", err)
}
return nil
}
func (c *api) getRecords(id string) ([]domainRecord, error) {
params := requestParams{"domain-name": id}
var bodyString, err = c.get("/dns/records.json", params)
if err != nil {
return nil, errors.Errorf("Error fetching record list from ClouDNS: %s", err)
}
var dr recordResponse
json.Unmarshal(bodyString, &dr)
var records []domainRecord
for _, rec := range dr {
records = append(records, rec)
}
return records, nil
}
func (c *api) get(endpoint string, params requestParams) ([]byte, error) {
client := &http.Client{}
req, _ := http.NewRequest("GET", "https://api.cloudns.net"+endpoint, nil)
q := req.URL.Query()
//TODO: Suport sub-auth-id / sub-auth-user https://asia.cloudns.net/wiki/article/42/
// Add auth params
q.Add("auth-id", c.creds.id)
q.Add("auth-password", c.creds.password)
for pName, pValue := range params {
q.Add(pName, pValue)
}
req.URL.RawQuery = q.Encode()
resp, err := client.Do(req)
if err != nil {
return []byte{}, err
}
bodyString, _ := ioutil.ReadAll(resp.Body)
// Got error from API ?
var errResp errorResponse
err = json.Unmarshal(bodyString, &errResp)
if errResp.Status == "Failed" {
return bodyString, errors.Errorf("ClouDNS API error: %s URL:%s%s ", errResp.Description, req.Host, req.URL.RequestURI())
}
return bodyString, nil
}
func fixTTL(ttl uint32) uint32 {
// if the TTL is larger than the largest allowed value, return the largest allowed value
if ttl > allowedTTLValues[len(allowedTTLValues)-1] {
return allowedTTLValues[len(allowedTTLValues)-1]
}
for _, v := range allowedTTLValues {
if v >= ttl {
return v
}
}
return allowedTTLValues[0]
}

View file

@ -0,0 +1,253 @@
package cloudns
import (
"encoding/json"
"fmt"
"github.com/StackExchange/dnscontrol/models"
"github.com/StackExchange/dnscontrol/providers"
"github.com/StackExchange/dnscontrol/providers/diff"
"github.com/miekg/dns/dnsutil"
"github.com/pkg/errors"
"strconv"
)
/*
CloDNS API DNS provider:
Info required in `creds.json`:
- auth-id
- auth-password
*/
// NewCloudns creates the provider.
func NewCloudns(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
c := &api{}
c.creds.id, c.creds.password = m["auth-id"], m["auth-password"]
if c.creds.id == "" || c.creds.password == "" {
return nil, errors.Errorf("missing ClouDNS auth-id and auth-password")
}
// Get a domain to validate authentication
if err := c.fetchDomainList(); err != nil {
return nil, err
}
return c, nil
}
var features = providers.DocumentationNotes{
providers.DocDualHost: providers.Unimplemented(),
providers.DocOfficiallySupported: providers.Cannot(),
providers.DocCreateDomains: providers.Can(),
providers.CanUseAlias: providers.Can(),
providers.CanUseSRV: providers.Can(),
providers.CanUseSSHFP: providers.Can(),
providers.CanUseCAA: providers.Can(),
providers.CanUseTLSA: providers.Can(),
providers.CanUsePTR: providers.Unimplemented(),
}
func init() {
providers.RegisterDomainServiceProviderType("CLOUDNS", NewCloudns, features)
}
// GetNameservers returns the nameservers for a domain.
func (c *api) GetNameservers(domain string) ([]*models.Nameserver, error) {
if len(c.nameserversNames) == 0 {
c.fetchAvailableNameservers()
}
return models.StringsToNameservers(c.nameserversNames), nil
}
// GetDomainCorrections returns the corrections for a domain.
func (c *api) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
dc, err := dc.Copy()
if err != nil {
return nil, err
}
dc.Punycode()
if c.domainIndex == nil {
if err := c.fetchDomainList(); err != nil {
return nil, err
}
}
domainID, ok := c.domainIndex[dc.Name]
if !ok {
return nil, errors.Errorf("%s not listed in domains for ClouDNS account", dc.Name)
}
records, err := c.getRecords(domainID)
if err != nil {
return nil, err
}
existingRecords := make([]*models.RecordConfig, len(records), len(records)+len(c.nameserversNames))
for i := range records {
existingRecords[i] = toRc(dc, &records[i])
}
// Normalize
models.PostProcessRecords(existingRecords)
// ClouDNS doesn't allow selecting an arbitrary TTL, only a set of predefined values https://asia.cloudns.net/wiki/article/188/
// We need to make sure we don't change it every time if it is as close as it's going to get
for _, record := range dc.Records {
record.TTL = fixTTL(record.TTL)
}
differ := diff.New(dc)
_, create, del, modify := differ.IncrementalDiff(existingRecords)
var corrections []*models.Correction
// Deletes first so changing type works etc.
for _, m := range del {
id := m.Existing.Original.(*domainRecord).ID
corr := &models.Correction{
Msg: fmt.Sprintf("%s, ClouDNS ID: %s", m.String(), id),
F: func() error {
return c.deleteRecord(domainID, id)
},
}
corrections = append(corrections, corr)
}
for _, m := range create {
req, err := toReq(m.Desired)
if err != nil {
return nil, err
}
corr := &models.Correction{
Msg: m.String(),
F: func() error {
return c.createRecord(domainID, req)
},
}
corrections = append(corrections, corr)
}
for _, m := range modify {
id := m.Existing.Original.(*domainRecord).ID
req, err := toReq(m.Desired)
if err != nil {
return nil, err
}
corr := &models.Correction{
Msg: fmt.Sprintf("%s, ClouDNS ID: %s: ", m.String(), id),
F: func() error {
return c.modifyRecord(domainID, id, req)
},
}
corrections = append(corrections, corr)
}
return corrections, nil
}
// EnsureDomainExists returns an error if domain doesn't exist.
func (c *api) EnsureDomainExists(domain string) error {
if err := c.fetchDomainList(); err != nil {
return err
}
// domain already exists
if _, ok := c.domainIndex[domain]; ok {
return nil
}
return c.createDomain(domain)
}
func toRc(dc *models.DomainConfig, r *domainRecord) *models.RecordConfig {
ttl, _ := strconv.ParseUint(r.TTL, 10, 32)
priority, _ := strconv.ParseUint(r.Priority, 10, 32)
weight, _ := strconv.ParseUint(r.Weight, 10, 32)
port, _ := strconv.ParseUint(r.Port, 10, 32)
rc := &models.RecordConfig{
Type: r.Type,
TTL: uint32(ttl),
MxPreference: uint16(priority),
SrvPriority: uint16(priority),
SrvWeight: uint16(weight),
SrvPort: uint16(port),
Original: r,
}
rc.SetLabel(r.Host, dc.Name)
switch rtype := r.Type; rtype { // #rtype_variations
case "TXT":
rc.SetTargetTXT(r.Target)
case "CNAME", "MX", "NS", "SRV", "ALIAS":
rc.SetTarget(dnsutil.AddOrigin(r.Target+".", dc.Name))
case "CAA":
caaFlag, _ := strconv.ParseUint(r.CaaFlag, 10, 32)
rc.CaaFlag = uint8(caaFlag)
rc.CaaTag = r.CaaTag
rc.SetTarget(r.CaaValue)
case "TLSA":
tlsaUsage, _ := strconv.ParseUint(r.TlsaUsage, 10, 32)
rc.TlsaUsage = uint8(tlsaUsage)
tlsaSelector, _ := strconv.ParseUint(r.TlsaSelector, 10, 32)
rc.TlsaSelector = uint8(tlsaSelector)
tlsaMatchingType, _ := strconv.ParseUint(r.TlsaMatchingType, 10, 32)
rc.TlsaMatchingType = uint8(tlsaMatchingType)
rc.SetTarget(r.Target)
case "SSHFP":
sshfpAlgorithm, _ := strconv.ParseUint(r.SshfpAlgorithm, 10, 32)
rc.SshfpAlgorithm = uint8(sshfpAlgorithm)
sshfpFingerprint, _ := strconv.ParseUint(r.SshfpFingerprint, 10, 32)
rc.SshfpFingerprint = uint8(sshfpFingerprint)
rc.SetTarget(r.Target)
default:
rc.SetTarget(r.Target)
}
return rc
}
func toReq(rc *models.RecordConfig) (requestParams, error) {
req := requestParams{
"record-type": rc.Type,
"host": rc.GetLabel(),
"record": rc.GetTargetField(),
"ttl": strconv.Itoa(int(rc.TTL)),
}
// ClouDNS doesn't use "@", it uses an empty name
if req["host"] == "@" {
req["host"] = ""
}
switch rc.Type { // #rtype_variations
case "A", "AAAA", "NS", "PTR", "TXT", "SOA", "ALIAS", "CNAME":
// Nothing special.
case "MX":
req["priority"] = strconv.Itoa(int(rc.MxPreference))
case "SRV":
req["priority"] = strconv.Itoa(int(rc.SrvPriority))
req["weight"] = strconv.Itoa(int(rc.SrvWeight))
req["port"] = strconv.Itoa(int(rc.SrvPort))
case "CAA":
req["caa_flag"] = strconv.Itoa(int(rc.CaaFlag))
req["caa_type"] = rc.CaaTag
req["caa_value"] = rc.Target
case "TLSA":
req["tlsa_usage"] = strconv.Itoa(int(rc.TlsaUsage))
req["tlsa_selector"] = strconv.Itoa(int(rc.TlsaSelector))
req["tlsa_matching_type"] = strconv.Itoa(int(rc.TlsaMatchingType))
case "SSHFP":
req["algorithm"] = strconv.Itoa(int(rc.SshfpAlgorithm))
req["fptype"] = strconv.Itoa(int(rc.SshfpFingerprint))
default:
msg := fmt.Sprintf("ClouDNS.toReq rtype %v unimplemented", rc.Type)
panic(msg)
// We panic so that we quickly find any switch statements
}
return req, nil
}