From 253cd07154796a1468652b42395aed7cc3229483 Mon Sep 17 00:00:00 2001 From: Anton Yurchenko Date: Tue, 21 Jan 2020 02:07:38 +0700 Subject: [PATCH] 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 --- OWNERS | 1 + README.md | 1 + docs/_providers/cloudns.md | 58 ++++++ docs/provider-list.md | 1 + integrationTest/providers.json | 7 + providers/_all/all.go | 1 + providers/cloudns/api.go | 235 +++++++++++++++++++++++++ providers/cloudns/cloudnsProvider.go | 253 +++++++++++++++++++++++++++ 8 files changed, 557 insertions(+) create mode 100644 docs/_providers/cloudns.md create mode 100644 providers/cloudns/api.go create mode 100644 providers/cloudns/cloudnsProvider.go diff --git a/OWNERS b/OWNERS index 97573f6b5..2d45d946f 100644 --- a/OWNERS +++ b/OWNERS @@ -2,6 +2,7 @@ providers/azuredns @vatsalyagoel providers/bind @tlimoncelli # providers/cloudflare +providers/cloudns @pragmaton providers/digitalocean @Deraen providers/dnsimple @aeden providers/gandi @TomOnTime diff --git a/README.md b/README.md index 6afaf81ce..efc56a0b8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Currently supported DNS providers: - Azure DNS - BIND - Cloudflare + - ClouDNS - DigitalOcean - DNSimple - Exoscale diff --git a/docs/_providers/cloudns.md b/docs/_providers/cloudns.md new file mode 100644 index 000000000..9200f21c6 --- /dev/null +++ b/docs/_providers/cloudns.md @@ -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. diff --git a/docs/provider-list.md b/docs/provider-list.md index 646fbef9d..8b89895d9 100644 --- a/docs/provider-list.md +++ b/docs/provider-list.md @@ -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 diff --git a/integrationTest/providers.json b/integrationTest/providers.json index a0f3d6939..0f58229d4 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -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", diff --git a/providers/_all/all.go b/providers/_all/all.go index 3accfac59..3d95aaf72 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -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" diff --git a/providers/cloudns/api.go b/providers/cloudns/api.go new file mode 100644 index 000000000..5c64c7335 --- /dev/null +++ b/providers/cloudns/api.go @@ -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] +} diff --git a/providers/cloudns/cloudnsProvider.go b/providers/cloudns/cloudnsProvider.go new file mode 100644 index 000000000..d0eed11eb --- /dev/null +++ b/providers/cloudns/cloudnsProvider.go @@ -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 +}