From 752e25471dcafa8a0a4035913a4d8274d66bbb01 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli Date: Sun, 12 Jun 2022 16:01:08 -0400 Subject: [PATCH] NEW PROVIDER: CSCGLOBAL as DNS Service Provider (#1516) * Move the registrar features to a separate file * Prepare the testing framework * Roughed out functions * Fix up structs * WIP! * First tests pass * wip! * Flesh out remaining rTypes, get nameservers, etc * Fix TXT records * Clean up code * More cleanups. Fix CAA/SRV * Linting * Cleanups/linting * Fix CAA [more] and more cleanups * CSC does not like very long txt records * Use timer only when interactive * Disable CAA for now * Update docs * Remove debug printf * add go-isatty * cleanups --- docs/_providers/cscglobal.md | 5 + docs/writing-providers.md | 2 +- go.mod | 2 +- integrationTest/integration_test.go | 18 +- integrationTest/providers.json | 6 + models/provider.go | 2 +- models/target.go | 4 + pkg/credsfile/providerConfig.go | 2 +- pkg/normalize/validate.go | 2 +- providers/cscglobal/api.go | 526 ++++++++++++++++++++++- providers/cscglobal/auditrecords.go | 29 ++ providers/cscglobal/convert.go | 129 ++++++ providers/cscglobal/cscglobalProvider.go | 66 ++- providers/cscglobal/dns.go | 314 ++++++++++++++ providers/cscglobal/listzones.go | 7 + providers/cscglobal/registrar.go | 42 ++ providers/providers.go | 6 +- 17 files changed, 1093 insertions(+), 69 deletions(-) create mode 100644 providers/cscglobal/convert.go create mode 100644 providers/cscglobal/dns.go create mode 100644 providers/cscglobal/listzones.go create mode 100644 providers/cscglobal/registrar.go diff --git a/docs/_providers/cscglobal.md b/docs/_providers/cscglobal.md index 3f9aa94b0..90e6df67f 100644 --- a/docs/_providers/cscglobal.md +++ b/docs/_providers/cscglobal.md @@ -8,6 +8,11 @@ jsId: CSCGLOBAL DNSControl's CSC Global provider supports being a Registrar. Support for being a DNS Provider is not included, although CSC Global's API does provide for this so it could be implemented in the future. +NOTE: Experimental support for being a DNS Provider is available. +However it is not recommended as updates take 5-7 minutes, and the +next update is not permitted until the previous update is complete. +Use it at your own risk. Consider it experimental and undocumented. + ## Configuration To use this provider, add an entry to `creds.json` with `TYPE` set to `CSCGLOBAL`. diff --git a/docs/writing-providers.md b/docs/writing-providers.md index b8595ea53..7311b0bdf 100644 --- a/docs/writing-providers.md +++ b/docs/writing-providers.md @@ -98,7 +98,7 @@ into three general categories: has A and MX records), you have to replace all the records at that label. (GANDI_V5) * **incremental-label-type:** Like incremental-record, but updates to any records at a label have to be done by type. For example, if a label (www.example.com) has many A and MX records, even the smallest change to one of the A records requires replacing all the A records. Any changes to the MX records requires replacing all the MX records. If an A record is converted to a CNAME, one must remove all the A records in one call, and add the CNAME record with another call. This is deceptively difficult to get right; if you have the choice between incremental-label-type and incremental-label, pick incremental-label. (DESEC, ROUTE53) -* **registrar only:** These providers are registrars but do not provide DNS service. (CSCGLOBAL, EASYNAME, INTERNETBS, OPENSRS) +* **registrar only:** These providers are registrars but do not provide DNS service. (EASYNAME, INTERNETBS, OPENSRS) All DNS providers use the "diff" module to detect differences. It takes two zones and returns records that are unchanged, created, deleted, diff --git a/go.mod b/go.mod index 77142b364..cdd725950 100644 --- a/go.mod +++ b/go.mod @@ -62,6 +62,7 @@ require ( require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/mattn/go-isatty v0.0.14 golang.org/x/exp v0.0.0-20220602145555-4a0574d9293f ) @@ -129,7 +130,6 @@ require ( github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b // indirect github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect github.com/mattn/go-colorable v0.1.9 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-testing-interface v1.0.0 // indirect diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index b9ae6e7de..e30f4ad27 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -532,6 +532,10 @@ func ttl(r *models.RecordConfig, t uint32) *models.RecordConfig { return r } +// gentxt generates TXTmulti test cases. The input string is used to +// dictate the output, each char represents the substring in the +// resulting TXTmulti. 0 or s outputs a short string, h outputs a 128-octet +// string, 1 or l outputs a long (255-octet) string. func gentxt(s string) *TestCase { title := fmt.Sprintf("Create TXT %s", s) label := fmt.Sprintf("foo%d", len(s)) @@ -760,6 +764,7 @@ func makeTests(t *testing.T) []*TestGroup { not( "AUTODNS", "AZURE_DNS", + "CSCGLOBAL", // Last verified 2022-06-07 "DIGITALOCEAN", "DNSIMPLE", "GANDI_V5", @@ -900,7 +905,10 @@ func makeTests(t *testing.T) []*TestGroup { tc("Create TXT with double-quote", txt("foodq", `quo"te`)), clear(), tc("Create TXT with ws at end", txt("foows1", "with space at end ")), - clear(), + ), + + // + testgroup("gentxt TXT", gentxt("0"), gentxt("1"), gentxt("10"), @@ -1030,6 +1038,7 @@ func makeTests(t *testing.T) []*TestGroup { // - DIGITALOCEAN: page size is 100 (default: 20) not( "CLOUDFLAREAPI", // Infinite pagesize but due to slow speed, skipping. + "CSCGLOBAL", // Doesn't page. Works fine. Due to the slow API we skip. "GANDI_V5", // Their API is so damn slow. We'll add it back as needed. "MSDNS", // No paging done. No need to test. "NAMEDOTCOM", // Their API is so damn slow. We'll add it back as needed. @@ -1042,10 +1051,11 @@ func makeTests(t *testing.T) []*TestGroup { testgroup("pager601", only( - //"MSDNS", // No paging done. No need to test. //"AZURE_DNS", // Currently failing. - "HEXONET", + //"CSCGLOBAL", // Doesn't page. Works fine. Due to the slow API we skip. "GCLOUD", + "HEXONET", + //"MSDNS", // No paging done. No need to test. "ROUTE53", ), tc("601 records", manyA("rec%04d", "1.2.3.4", 600)...), @@ -1057,6 +1067,7 @@ func makeTests(t *testing.T) []*TestGroup { //"AKAMAIEDGEDNS", // No paging done. No need to test. //"AZURE_DNS", // Currently failing. See https://github.com/StackExchange/dnscontrol/issues/770 //"CLOUDFLAREAPI", // Fails with >1000 corrections. See https://github.com/StackExchange/dnscontrol/issues/1440 + //"CSCGLOBAL", // Doesn't page. Works fine. Due to the slow API we skip. "HEXONET", "HOSTINGDE", //"MSDNS", // No paging done. No need to test. @@ -1144,6 +1155,7 @@ func makeTests(t *testing.T) []*TestGroup { ), testgroup("SRV w/ null target", requires(providers.CanUseSRV), not( + "CSCGLOBAL", // Not supported. "EXOSCALE", // Not supported. "HEXONET", // Not supported. "INWX", // Not supported. diff --git a/integrationTest/providers.json b/integrationTest/providers.json index d82148afc..4352bd3fc 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -27,6 +27,12 @@ "BIND": { "domain": "$BIND_DOMAIN" }, + "CSCGLOBAL": { + "api-key": "$CSCGLOBAL_APIKEY", + "user-token": "$CSCGLOBAL_USERTOKEN", + "notification_emails": "$CSCGLOBAL_NOTIFICATION", + "domain": "$CSCGLOBAL_DOMAIN" + }, "CLOUDFLAREAPI": { "apikey": "$CLOUDFLAREAPI_KEY", "apitoken": "$CLOUDFLAREAPI_TOKEN", diff --git a/models/provider.go b/models/provider.go index 54c07f28e..9a254231c 100644 --- a/models/provider.go +++ b/models/provider.go @@ -3,8 +3,8 @@ package models // DNSProvider is an interface for DNS Provider plug-ins. type DNSProvider interface { GetNameservers(domain string) ([]*Nameserver, error) - GetDomainCorrections(dc *DomainConfig) ([]*Correction, error) GetZoneRecords(domain string) (Records, error) + GetDomainCorrections(dc *DomainConfig) ([]*Correction, error) } // Registrar is an interface for Registrar plug-ins. diff --git a/models/target.go b/models/target.go index 78d2db48b..f082eb50d 100644 --- a/models/target.go +++ b/models/target.go @@ -23,6 +23,10 @@ so that it is easy to do things the right way in preparation. // GetTargetField returns the target. There may be other fields (for example // an MX record also has a .MxPreference field. func (rc *RecordConfig) GetTargetField() string { + //if rc.Type == "TXT" { + // fmt.Printf("DEBUG: WARNING: GetTargetField called on TXT record is usually wrong: %q\n", rc.target) + // //debug.PrintStack() + //} return rc.target } diff --git a/pkg/credsfile/providerConfig.go b/pkg/credsfile/providerConfig.go index 6ea7a6c8a..c9f698657 100644 --- a/pkg/credsfile/providerConfig.go +++ b/pkg/credsfile/providerConfig.go @@ -1,4 +1,4 @@ -// Package config provides functions for reading and parsing the provider credentials json file. +// Package credsfile provides functions for reading and parsing the provider credentials json file. // It cleans nonstandard json features (comments and trailing commas), as well as replaces environment variable placeholders with // their environment variable equivalents. To reference an environment variable in your json file, simply use values in this format: // "key"="$ENV_VAR_NAME" diff --git a/pkg/normalize/validate.go b/pkg/normalize/validate.go index 758a37811..3849e5aa0 100644 --- a/pkg/normalize/validate.go +++ b/pkg/normalize/validate.go @@ -598,7 +598,7 @@ func checkLabelHasMultipleTTLs(records []*models.RecordConfig) (errs []error) { for label := range m { // if after the uniq() pass we still have more than one ttl, it means we have multiple TTLs for that label if len(uniq(m[label])) > 1 { - errs = append(errs, Warning{fmt.Errorf("multiple TTLs detected for: %s. This should be avoided.", label)}) + errs = append(errs, Warning{fmt.Errorf("multiple TTLs detected for: %s. This should be avoided", label)}) } } return errs diff --git a/providers/cscglobal/api.go b/providers/cscglobal/api.go index a5c2ae723..31084c417 100644 --- a/providers/cscglobal/api.go +++ b/providers/cscglobal/api.go @@ -6,19 +6,18 @@ import ( "fmt" "io/ioutil" "net/http" + "os" "sort" + "strings" + "time" + + "github.com/mattn/go-isatty" ) const apiBase = "https://apis.cscglobal.com/dbs/api/v2" // Api layer for CSC Global -type cscglobalProvider struct { - key string - token string - notifyEmails []string -} - type requestParams map[string]string type errorResponse struct { @@ -55,8 +54,154 @@ type domainRecord struct { Nameserver []string `json:"nameservers"` } -func (c *cscglobalProvider) getNameservers(domain string) ([]string, error) { - var bodyString, err = c.get("/domains/" + domain) +// Get zone + +type nativeRecordA = struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + TTL uint32 `json:"ttl"` + Status string `json:"status"` +} +type nativeRecordCNAME = struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + TTL uint32 `json:"ttl"` + Status string `json:"status"` +} +type nativeRecordAAAA = struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + TTL uint32 `json:"ttl"` + Status string `json:"status"` +} +type nativeRecordTXT = struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + TTL uint32 `json:"ttl"` + Status string `json:"status"` +} +type nativeRecordMX = struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + TTL uint32 `json:"ttl"` + Status string `json:"status"` + Priority uint16 `json:"priority"` +} +type nativeRecordNS = struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + TTL uint32 `json:"ttl"` + Status string `json:"status"` + Priority int `json:"priority"` +} +type nativeRecordSRV = struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + TTL uint32 `json:"ttl"` + Status string `json:"status"` + Priority uint16 `json:"priority"` + Weight uint16 `json:"weight"` + Port uint16 `json:"port"` +} +type nativeRecordCAA = struct { + ID string `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + TTL uint32 `json:"ttl"` + Status string `json:"status"` + Tag string `json:"tag"` + Flag uint8 `json:"flag"` +} +type nativeRecordSOA = struct { + Serial int `json:"serial"` + Refresh int `json:"refresh"` + Retry int `json:"retry"` + Expire int `json:"expire"` + TTL uint32 `json:"ttlMin"` + TTLNeg int `json:"ttlNeg"` + TTLZone int `json:"ttlZone"` + TechEmail string `json:"techEmail"` + MasterHost string `json:"masterHost"` +} + +type zoneResponse struct { + ZoneName string `json:"zoneName"` + HostingType string `json:"hostingType"` + A []nativeRecordA `json:"a"` + Cname []nativeRecordCNAME `json:"cname"` + Aaaa []nativeRecordAAAA `json:"aaaa"` + Txt []nativeRecordTXT `json:"txt"` + Mx []nativeRecordMX `json:"mx"` + Ns []nativeRecordNS `json:"ns"` + Srv []nativeRecordSRV `json:"srv"` + Caa []nativeRecordCAA `json:"caa"` + Soa nativeRecordSOA `json:"soa"` +} + +// Zone edits + +type zoneResourceRecordEdit = struct { + Action string `json:"action"` + RecordType string `json:"recordType"` + CurrentKey string `json:"currentKey,omitempty"` + CurrentValue string `json:"currentValue,omitempty"` + NewKey string `json:"newKey,omitempty"` + NewValue string `json:"newValue,omitempty"` + NewTTL uint32 `json:"newTtl,omitempty"` + // MX and SRV: + NewPriority uint16 `json:"newPriority,omitempty"` + // SRV: + NewWeight uint16 `json:"newWeight,omitempty"` + NewPort uint16 `json:"newPort,omitempty"` + // CAA: + // These are pointers so that we can display the zero-value on demand. If + // they were not pointers, the zero-value ("" and 0) would result in no JSON + // output for those fields. Sometimes we want to generate fields with + // zero-values, such as `"newTag":""`. Thus we make these pointers. The + // zero-value is now "nil". If we want the field to appear in the JSON, we + // set the pointer to a value. It is no longer nil, and will be output even + // if the value at the pointer is zero-value. + // See: https://emretanriverdi.medium.com/json-serialization-in-go-a27aeeb968de + CurrentTag *string `json:"currentTag,omitempty"` + NewTag *string `json:"newTag,omitempty"` // "" needs to be sent explicitly. + NewFlag *uint8 `json:"newFlag,omitempty"` // 0 needs to be sent explictly. +} + +type zoneEditRequest = struct { + ZoneName string `json:"zoneName"` + Edits *[]zoneResourceRecordEdit `json:"edits"` +} + +type zoneEditRequestResultZoneEditRequestResult struct { + Content struct { + Status string `json:"status"` + Message string `json:"message"` + } `json:"content"` + Links struct { + Self string `json:"self"` + Status string `json:"status"` + } `json:"links"` +} + +type zoneEditStatusResultZoneEditStatusResult struct { + Content struct { + Status string `json:"status"` + ErrorDescription string `json:"errorDescription"` + } `json:"content"` + Links struct { + Cancel string `json:"cancel"` + } `json:"links"` +} + +func (client *providerClient) getNameservers(domain string) ([]string, error) { + var bodyString, err = client.get("/domains/" + domain) if err != nil { return nil, err } @@ -69,16 +214,16 @@ func (c *cscglobalProvider) getNameservers(domain string) ([]string, error) { return ns, nil } -func (c *cscglobalProvider) updateNameservers(ns []string, domain string) error { +func (client *providerClient) updateNameservers(ns []string, domain string) error { req := nsModRequest{ Domain: domain, NameServers: ns, DNSType: "OTHER_DNS", ShowPrice: false, } - if c.notifyEmails != nil { + if client.notifyEmails != nil { req.Notifications.Enabled = true - req.Notifications.Emails = c.notifyEmails + req.Notifications.Emails = client.notifyEmails } req.CustomFields = []string{} @@ -87,7 +232,7 @@ func (c *cscglobalProvider) updateNameservers(ns []string, domain string) error return err } - bodyString, err := c.put("/domains/nsmodification", requestBody) + bodyString, err := client.put("/domains/nsmodification", requestBody) if err != nil { return fmt.Errorf("CSC Global: Error update NS : %w", err) } @@ -101,17 +246,273 @@ func (c *cscglobalProvider) updateNameservers(ns []string, domain string) error return nil } -func (c *cscglobalProvider) put(endpoint string, requestBody []byte) ([]byte, error) { - client := &http.Client{} +// domainsResult is the JSON returned by "/domains". Fields we don't +// use are commented out. +type domainsResult struct { + Meta struct { + NumResults int `json:"numResults"` + Pages int `json:"pages"` + } `json:"meta"` + Domains []struct { + QualifiedDomainName string `json:"qualifiedDomainName"` + // Domain string `json:"domain"` + // Idn string `json:"idn"` + // Extension string `json:"extension"` + // NewGtld bool `json:"newGtld"` + // ManagedStatus string `json:"managedStatus"` + // RegistrationDate string `json:"registrationDate"` + // RegistryExpiryDate string `json:"registryExpiryDate"` + // PaidThroughDate string `json:"paidThroughDate"` + // CountryCode string `json:"countryCode"` + // ServerDeleteProhibited bool `json:"serverDeleteProhibited"` + // ServerTransferProhibited bool `json:"serverTransferProhibited"` + // ServerUpdateProhibited bool `json:"serverUpdateProhibited"` + // DNSType string `json:"dnsType"` + // WhoisPrivacy bool `json:"whoisPrivacy"` + // LocalAgent bool `json:"localAgent"` + // DnssecActivated string `json:"dnssecActivated"` + // CriticalDomain bool `json:"criticalDomain"` + // BusinessUnit string `json:"businessUnit"` + // BrandName string `json:"brandName"` + // IdnReferenceName string `json:"idnReferenceName"` + // CustomFields []interface{} `json:"customFields"` + // Account struct { + // AccountNumber string `json:"accountNumber"` + // AccountName string `json:"accountName"` + // } `json:"account"` + // Urlf struct { + // RedirectType string `json:"redirectType"` + // URLForwarding bool `json:"urlForwarding"` + // } `json:"urlf"` + // NameServers []string `json:"nameServers"` + // WhoisContacts []struct { + // ContactType string `json:"contactType"` + // FirstName string `json:"firstName"` + // LastName string `json:"lastName"` + // Organization string `json:"organization"` + // Street1 string `json:"street1"` + // Street2 string `json:"street2"` + // City string `json:"city"` + // StateProvince string `json:"stateProvince"` + // Country string `json:"country"` + // PostalCode string `json:"postalCode"` + // Email string `json:"email"` + // Phone string `json:"phone"` + // PhoneExtn string `json:"phoneExtn"` + // Fax string `json:"fax"` + // } `json:"whoisContacts"` + // LastModifiedDate string `json:"lastModifiedDate"` + // LastModifiedReason string `json:"lastModifiedReason"` + // LastModifiedDescription string `json:"lastModifiedDescription"` + } `json:"domains"` + // Links struct { + // Self string `json:"self"` + // } `json:"links"` +} + +func (client *providerClient) getDomains() ([]string, error) { + var bodyString, err = client.get("/domains") + if err != nil { + return nil, err + } + + //fmt.Printf("------------------\n") + //fmt.Printf("DEBUG: GETDOMAINS bodystring = %s\n", bodyString) + //fmt.Printf("------------------\n") + + var dr domainsResult + json.Unmarshal(bodyString, &dr) + + if dr.Meta.Pages > 1 { + return nil, fmt.Errorf("cscglobal getDomains: unimplemented paganation") + } + + var r []string + for _, d := range dr.Domains { + r = append(r, d.QualifiedDomainName) + } + + //fmt.Printf("------------------\n") + //fmt.Printf("DEBUG: GETDOMAINS dr = %+v\n", dr) + //fmt.Printf("------------------\n") + + return r, nil +} + +func (client *providerClient) getZoneRecordsAll(zone string) (*zoneResponse, error) { + var bodyString, err = client.get("/zones/" + zone) + if err != nil { + return nil, err + } + + if cscDebug { + fmt.Printf("------------------\n") + fmt.Printf("DEBUG: ZONE RESPONSE = %s\n", bodyString) + fmt.Printf("------------------\n") + } + + var dr zoneResponse + json.Unmarshal(bodyString, &dr) + + return &dr, nil +} + +func (client *providerClient) sendZoneEditRequest(domainname string, edits []zoneResourceRecordEdit) error { + + req := zoneEditRequest{ + ZoneName: domainname, + Edits: &edits, + } + + requestBody, err := json.Marshal(req) + if err != nil { + return err + } + if cscDebug { + fmt.Printf("DEBUG: edit request = %s\n", requestBody) + } + responseBody, err := client.post("/zones/edits", requestBody) + if err != nil { + return err + } + + var errResp zoneEditRequestResultZoneEditRequestResult + err = json.Unmarshal(responseBody, &errResp) + if err != nil { + return fmt.Errorf("CSC Global API error: %s DATA: %q", err, errResp) + } + if errResp.Content.Status != "SUCCESS" { + return fmt.Errorf("CSC Global API error: %s DATA: %q", errResp.Content.Status, errResp.Content.Message) + } + + // The request was successfully submitted. Now query the status link until the request is complete. + statusURL := errResp.Links.Status + return client.waitRequestURL(statusURL) +} + +func (client *providerClient) waitRequest(reqID string) error { + return client.waitRequestURL(apiBase + "/zones/edits/status/" + reqID) +} + +func (client *providerClient) waitRequestURL(statusURL string) error { + t1 := time.Now() + for { + statusBody, err := client.geturl(statusURL) + if err != nil { + fmt.Println() + return fmt.Errorf("CSC Global API error: %s DATA: %q", err, statusBody) + } + var statusResp zoneEditStatusResultZoneEditStatusResult + err = json.Unmarshal(statusBody, &statusResp) + if err != nil { + fmt.Println() + return fmt.Errorf("CSC Global API error: %s DATA: %q", err, statusBody) + } + status, msg := statusResp.Content.Status, statusResp.Content.ErrorDescription + + if isatty.IsTerminal(os.Stdout.Fd()) { + dur := time.Since(t1).Round(time.Second) + if msg == "" { + fmt.Printf("WAITING: % 6s STATUS=%s \r", dur, status) + } else { + fmt.Printf("WAITING: % 6s STATUS=%s MSG=%q \r", dur, status, msg) + } + } + if status == "FAILED" { + fmt.Println() + parts := strings.Split(statusResp.Links.Cancel, "/") + client.cancelRequest(parts[len(parts)-1]) + return fmt.Errorf("update failed: %s %s", msg, statusURL) + } + if status == "COMPLETED" { + fmt.Println() + break + } + time.Sleep(1 * time.Second) + } + return nil + + // Response looks like: + //{ + // "content": { + // "status": "SUCCESS", + // "message": "The publish request was successfully enqueued." + // }, + // "links": { + // "self": "https://apis.cscglobal.com/dbs/api/v2/zones/edits/9e139e34-a2a1-462e-88ab-3645833a55d4", + // "status": "https://apis.cscglobal.com/dbs/api/v2/zones/edits/status/9e139e34-a2a1-462e-88ab-3645833a55d4" + // } + // } +} + +// Cancel pending/stuck edits + +type pagedZoneEditResponsePagedZoneEditResponse struct { + Meta struct { + NumResults int `json:"numResults"` + Pages int `json:"pages"` + } `json:"meta"` + ZoneEdits []struct { + ZoneName string `json:"zoneName"` + ID string `json:"id"` + Status string `json:"status"` + } `json:"zoneEdits"` +} + +func (client *providerClient) clearRequests(domain string) error { + var bodyString, err = client.get("/zones/edits?filter=zoneName==" + domain) + if err != nil { + return err + } + + var dr pagedZoneEditResponsePagedZoneEditResponse + json.Unmarshal(bodyString, &dr) + + // TODO(tlim): Properly handle paganation. + if dr.Meta.Pages != 1 { + return fmt.Errorf("cancelPendingEdits failed: Pages=%d", dr.Meta.Pages) + } + + for i, ze := range dr.ZoneEdits { + if cscDebug { + if ze.Status != "COMPLETED" && ze.Status != "CANCELED" { + fmt.Printf("REQUEST %d: %s %s\n", i, ze.ID, ze.Status) + } + } + switch ze.Status { + case "PROPAGATING": + fmt.Printf("INFO: Waiting for id=%s status=%s\n", ze.ID, ze.Status) + client.waitRequest(ze.ID) + case "FAILED": + fmt.Printf("INFO: Deleting request status=%s id=%s\n", ze.Status, ze.ID) + client.cancelRequest(ze.ID) + case "COMPLETED", "CANCELED": + continue + default: + return fmt.Errorf("cscglobal ClearRequests: unimplemented status: %q", ze.Status) + } + + } + + return nil +} + +func (client *providerClient) cancelRequest(reqID string) error { + _, err := client.delete("/zones/edits/" + reqID) + return err +} + +func (client *providerClient) put(endpoint string, requestBody []byte) ([]byte, error) { + hclient := &http.Client{} req, _ := http.NewRequest("PUT", apiBase+endpoint, bytes.NewReader(requestBody)) // Add headers - req.Header.Add("apikey", c.key) - req.Header.Add("Authorization", "Bearer "+c.token) + req.Header.Add("apikey", client.key) + req.Header.Add("Authorization", "Bearer "+client.token) req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") - resp, err := client.Do(req) + resp, err := hclient.Do(req) if err != nil { return nil, err } @@ -135,16 +536,95 @@ func (c *cscglobalProvider) put(endpoint string, requestBody []byte) ([]byte, er req.Host, req.URL.RequestURI()) } -func (c *cscglobalProvider) get(endpoint string) ([]byte, error) { - client := &http.Client{} - req, _ := http.NewRequest("GET", apiBase+endpoint, nil) +func (client *providerClient) delete(endpoint string) ([]byte, error) { + hclient := &http.Client{} + fmt.Printf("DEBUG: delete endpoint: %q\n", apiBase+endpoint) + req, _ := http.NewRequest("DELETE", apiBase+endpoint, nil) // Add headers - req.Header.Add("apikey", c.key) - req.Header.Add("Authorization", "Bearer "+c.token) + req.Header.Add("apikey", client.key) + req.Header.Add("Authorization", "Bearer "+client.token) + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/json") + + resp, err := hclient.Do(req) + if err != nil { + return nil, err + } + + bodyString, _ := ioutil.ReadAll(resp.Body) + if resp.StatusCode == 200 { + fmt.Printf("DEBUG: Delete successful (200)\n") + return bodyString, nil + } + fmt.Printf("DEBUG: Delete failed (%d)\n", resp.StatusCode) + + // Got a error response from API, see if it's json format + var errResp errorResponse + err = json.Unmarshal(bodyString, &errResp) + if err != nil { + // Some error messages are plain text + return nil, fmt.Errorf("CSC Global API error: %s URL: %s%s", + bodyString, + req.Host, req.URL.RequestURI()) + } + return nil, fmt.Errorf("CSC Global API error code: %s description: %s URL: %s%s", + errResp.Code, errResp.Description, + req.Host, req.URL.RequestURI()) +} + +func (client *providerClient) post(endpoint string, requestBody []byte) ([]byte, error) { + hclient := &http.Client{} + req, _ := http.NewRequest("POST", apiBase+endpoint, bytes.NewBuffer(requestBody)) + + // Add headers + req.Header.Add("apikey", client.key) + req.Header.Add("Authorization", "Bearer "+client.token) + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/json") + + resp, err := hclient.Do(req) + if err != nil { + return nil, err + } + + bodyString, _ := ioutil.ReadAll(resp.Body) + //fmt.Printf("------------------\n") + //fmt.Printf("DEBUG: resp.StatusCode == %d\n", resp.StatusCode) + //fmt.Printf("POST RESPONSE = %s\n", bodyString) + //fmt.Printf("------------------\n") + if resp.StatusCode == 201 { + return bodyString, nil + } + + // Got a error response from API, see if it's json format + var errResp errorResponse + err = json.Unmarshal(bodyString, &errResp) + if err != nil { + // Some error messages are plain text + return nil, fmt.Errorf("CSC Global API error: %s URL: %s%s", + bodyString, + req.Host, req.URL.RequestURI()) + } + return nil, fmt.Errorf("CSC Global API error code: %s description: %s URL: %s%s", + errResp.Code, errResp.Description, + req.Host, req.URL.RequestURI()) +} + +func (client *providerClient) get(endpoint string) ([]byte, error) { + return client.geturl(apiBase + endpoint) +} + +func (client *providerClient) geturl(url string) ([]byte, error) { + hclient := &http.Client{} + req, _ := http.NewRequest("GET", url, nil) + + // Add headers + req.Header.Add("apikey", client.key) + req.Header.Add("Authorization", "Bearer "+client.token) req.Header.Add("Accept", "application/json") - resp, err := client.Do(req) + resp, err := hclient.Do(req) if err != nil { return nil, err } diff --git a/providers/cscglobal/auditrecords.go b/providers/cscglobal/auditrecords.go index ce3f889f1..ae03871e8 100644 --- a/providers/cscglobal/auditrecords.go +++ b/providers/cscglobal/auditrecords.go @@ -2,10 +2,39 @@ package cscglobal import ( "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/recordaudit" ) // AuditRecords returns an error if any records are not // supportable by this provider. func AuditRecords(records []*models.RecordConfig) error { + + // Each test should be encapsulated in a function that can be tested + // individually. If the test is of general use, add it to the + // recordaudit module. + + // Each test should document the last time we verified the test was + // still needed. Sometimes companies change their API. + + if err := recordaudit.TxtNoDoubleQuotes(records); err != nil { + return err + } // Needed as of 2022-06-10 + + if err := recordaudit.TxtNoLen255(records); err != nil { + return err + } // Needed as of 2022-06-10 + + if err := recordaudit.TxtNoMultipleStrings(records); err != nil { + return err + } // Needed as of 2022-06-10 + + if err := recordaudit.TxtNoTrailingSpace(records); err != nil { + return err + } // Needed as of 2022-06-10 + + if err := recordaudit.TxtNotEmpty(records); err != nil { + return err + } // Needed as of 2022-06-10 + return nil } diff --git a/providers/cscglobal/convert.go b/providers/cscglobal/convert.go new file mode 100644 index 000000000..47cdc15de --- /dev/null +++ b/providers/cscglobal/convert.go @@ -0,0 +1,129 @@ +package cscglobal + +// Convert the provider's native record description to models.RecordConfig. + +import ( + "net" + + "github.com/StackExchange/dnscontrol/v3/models" +) + +// nativeToRecordA takes an A record from DNS and returns a native RecordConfig struct. +func nativeToRecordA(nr nativeRecordA, origin string, defaultTTL uint32) *models.RecordConfig { + ttl := nr.TTL + if ttl == 0 { + ttl = defaultTTL + } + rc := &models.RecordConfig{ + Type: "A", + TTL: ttl, + } + rc.SetLabel(nr.Key, origin) + rc.SetTargetIP(net.ParseIP(nr.Value).To4()) + return rc +} + +// nativeToRecordCNAME takes a CNAME record from DNS and returns a native RecordConfig struct. +func nativeToRecordCNAME(nr nativeRecordCNAME, origin string, defaultTTL uint32) *models.RecordConfig { + ttl := nr.TTL + if ttl == 0 { + ttl = defaultTTL + } + rc := &models.RecordConfig{ + Type: "CNAME", + TTL: ttl, + } + rc.SetLabel(nr.Key, origin) + rc.SetTarget(nr.Value) + return rc +} + +// nativeToRecordA takes an AAAA record from DNS and returns a native RecordConfig struct. +func nativeToRecordAAAA(nr nativeRecordAAAA, origin string, defaultTTL uint32) *models.RecordConfig { + ttl := nr.TTL + if ttl == 0 { + ttl = defaultTTL + } + rc := &models.RecordConfig{ + Type: "AAAA", + TTL: ttl, + } + rc.SetLabel(nr.Key, origin) + rc.SetTargetIP(net.ParseIP(nr.Value).To16()) + return rc +} + +// nativeToRecordTXT takes a TXT record from DNS and returns a native RecordConfig struct. +func nativeToRecordTXT(nr nativeRecordTXT, origin string, defaultTTL uint32) *models.RecordConfig { + ttl := nr.TTL + if ttl == 0 { + ttl = defaultTTL + } + rc := &models.RecordConfig{ + Type: "TXT", + TTL: ttl, + } + rc.SetLabel(nr.Key, origin) + rc.SetTargetTXT(nr.Value) + return rc +} + +// nativeToRecordMX takes a MX record from DNS and returns a native RecordConfig struct. +func nativeToRecordMX(nr nativeRecordMX, origin string, defaultTTL uint32) *models.RecordConfig { + ttl := nr.TTL + if ttl == 0 { + ttl = defaultTTL + } + rc := &models.RecordConfig{ + Type: "MX", + TTL: ttl, + } + rc.SetLabel(nr.Key, origin) + rc.SetTargetMX(nr.Priority, nr.Value) + return rc +} + +// nativeToRecordNS takes a NS record from DNS and returns a native RecordConfig struct. +func nativeToRecordNS(nr nativeRecordNS, origin string, defaultTTL uint32) *models.RecordConfig { + ttl := nr.TTL + if ttl == 0 { + ttl = defaultTTL + } + rc := &models.RecordConfig{ + Type: "NS", + TTL: ttl, + } + rc.SetLabel(nr.Key, origin) + rc.SetTarget(nr.Value) + return rc +} + +// nativeToRecordSRV takes a SRV record from DNS and returns a native RecordConfig struct. +func nativeToRecordSRV(nr nativeRecordSRV, origin string, defaultTTL uint32) *models.RecordConfig { + ttl := nr.TTL + if ttl == 0 { + ttl = defaultTTL + } + rc := &models.RecordConfig{ + Type: "SRV", + TTL: ttl, + } + rc.SetLabel(nr.Key, origin) + rc.SetTargetSRV(nr.Priority, nr.Weight, nr.Port, nr.Value) + return rc +} + +// nativeToRecordCAA takes a CAA record from DNS and returns a native RecordConfig struct. +func nativeToRecordCAA(nr nativeRecordCAA, origin string, defaultTTL uint32) *models.RecordConfig { + ttl := nr.TTL + if ttl == 0 { + ttl = defaultTTL + } + rc := &models.RecordConfig{ + Type: "CAA", + TTL: ttl, + } + rc.SetLabel(nr.Key, origin) + rc.SetTargetCAA(nr.Flag, nr.Tag, nr.Value) + return rc +} diff --git a/providers/cscglobal/cscglobalProvider.go b/providers/cscglobal/cscglobalProvider.go index 15e3d4feb..ddf8c199e 100644 --- a/providers/cscglobal/cscglobalProvider.go +++ b/providers/cscglobal/cscglobalProvider.go @@ -1,11 +1,10 @@ package cscglobal import ( + "encoding/json" "fmt" - "sort" "strings" - "github.com/StackExchange/dnscontrol/v3/models" "github.com/StackExchange/dnscontrol/v3/providers" ) @@ -19,12 +18,32 @@ Info required in `creds.json`: - notification_emails (optional) Comma separated list of email addresses to send notifications to */ -func init() { - providers.RegisterRegistrarType("CSCGLOBAL", newCscGlobal) +type providerClient struct { + key string + token string + notifyEmails []string } -func newCscGlobal(m map[string]string) (providers.Registrar, error) { - api := &cscglobalProvider{} +var features = providers.DocumentationNotes{ + providers.CanGetZones: providers.Can(), + //providers.CanUseCAA: providers.Can(), + providers.CanUseSRV: providers.Can(), + providers.DocOfficiallySupported: providers.Can(), +} + +// Set cscDebug to true if you want to see the JSON of important API requests and responses. +var cscDebug = false + +func newReg(conf map[string]string) (providers.Registrar, error) { + return newProvider(conf) +} + +func newDsp(conf map[string]string, meta json.RawMessage) (providers.DNSServiceProvider, error) { + return newProvider(conf) +} + +func newProvider(m map[string]string) (*providerClient, error) { + api := &providerClient{} api.key, api.token = m["api-key"], m["user-token"] if api.key == "" || api.token == "" { @@ -38,35 +57,12 @@ func newCscGlobal(m map[string]string) (providers.Registrar, error) { return api, nil } -// GetRegistrarCorrections gathers corrections that would being n to match dc. -func (c *cscglobalProvider) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { - nss, err := c.getNameservers(dc.Name) - if err != nil { - return nil, err - } - foundNameservers := strings.Join(nss, ",") +func init() { + providers.RegisterRegistrarType("CSCGLOBAL", newReg) - expected := []string{} - for _, ns := range dc.Nameservers { - if ns.Name[len(ns.Name)-1] == '.' { - // When this code was written ns.Name never included a single trailing dot. - // If that changes, the code should change too. - return nil, fmt.Errorf("name server includes a trailing dot, has the API changed?") - } - expected = append(expected, ns.Name) + fns := providers.DspFuncs{ + Initializer: newDsp, + RecordAuditor: AuditRecords, } - sort.Strings(expected) - expectedNameservers := strings.Join(expected, ",") - - if foundNameservers != expectedNameservers { - return []*models.Correction{ - { - Msg: fmt.Sprintf("Update nameservers %s -> %s", foundNameservers, expectedNameservers), - F: func() error { - return c.updateNameservers(expected, dc.Name) - }, - }, - }, nil - } - return nil, nil + providers.RegisterDomainServiceProviderType("CSCGLOBAL", fns, features) } diff --git a/providers/cscglobal/dns.go b/providers/cscglobal/dns.go new file mode 100644 index 000000000..ba2d5eadc --- /dev/null +++ b/providers/cscglobal/dns.go @@ -0,0 +1,314 @@ +package cscglobal + +import ( + "fmt" + "strings" + + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" +) + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (client *providerClient) GetZoneRecords(domain string) (models.Records, error) { + records, err := client.getZoneRecordsAll(domain) + if err != nil { + return nil, err + } + + // Convert them to DNScontrol's native format: + + existingRecords := []*models.RecordConfig{} + + // Option 1: One long list. If your provider returns one long list, + // convert each one to RecordType like this: + // for _, rr := range records { + // existingRecords = append(existingRecords, nativeToRecord(rr, domain)) + //} + + // Option 2: Grouped records. Sometimes the provider returns one item per + // label. Each item contains a list of all the records at that label. + // You'll need to split them out into one RecordConfig for each record. An + // example of this is the ROUTE53 provider. + // for _, rg := range records { + // for _, rr := range rg { + // existingRecords = append(existingRecords, nativeToRecords(rg, rr, domain)...) + // } + // } + + // Option 3: Something else. In this case, we get a big massive structure + // which needs to be broken up. Still, we're generating a list of + // RecordConfig structures. + defaultTTL := records.Soa.TTL + for _, rr := range records.A { + existingRecords = append(existingRecords, nativeToRecordA(rr, domain, defaultTTL)) + } + for _, rr := range records.Cname { + existingRecords = append(existingRecords, nativeToRecordCNAME(rr, domain, defaultTTL)) + } + for _, rr := range records.Aaaa { + existingRecords = append(existingRecords, nativeToRecordAAAA(rr, domain, defaultTTL)) + } + for _, rr := range records.Txt { + existingRecords = append(existingRecords, nativeToRecordTXT(rr, domain, defaultTTL)) + } + for _, rr := range records.Mx { + existingRecords = append(existingRecords, nativeToRecordMX(rr, domain, defaultTTL)) + } + for _, rr := range records.Ns { + existingRecords = append(existingRecords, nativeToRecordNS(rr, domain, defaultTTL)) + } + for _, rr := range records.Srv { + existingRecords = append(existingRecords, nativeToRecordSRV(rr, domain, defaultTTL)) + } + for _, rr := range records.Caa { + existingRecords = append(existingRecords, nativeToRecordCAA(rr, domain, defaultTTL)) + } + + return existingRecords, nil +} + +func (client *providerClient) GetNameservers(domain string) ([]*models.Nameserver, error) { + nss, err := client.getNameservers(domain) + if err != nil { + return nil, err + } + return models.ToNameservers(nss) +} + +// GetDomainCorrections get the current and existing records, +// post-process them, and generate corrections. +// NB(tlim): This function should be exactly the same in all DNS providers. Once +// all providers do this, we can eliminate it and use a Go interface instead. +func (client *providerClient) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + existing, err := client.GetZoneRecords(dc.Name) + if err != nil { + return nil, err + } + models.PostProcessRecords(existing) + //txtutil.SplitSingleLongTxt(dc.Records) // Autosplit long TXT records + + clean := PrepFoundRecords(existing) + PrepDesiredRecords(dc) + return client.GenerateDomainCorrections(dc, clean) +} + +// PrepFoundRecords munges any records to make them compatible with +// this provider. Usually this is a no-op. +func PrepFoundRecords(recs models.Records) models.Records { + // If there are records that need to be modified, removed, etc. we + // do it here. Usually this is a no-op. + return recs +} + +// PrepDesiredRecords munges any records to best suit this provider. +func PrepDesiredRecords(dc *models.DomainConfig) { + // Sort through the dc.Records, eliminate any that can't be + // supported; modify any that need adjustments to work with the + // provider. We try to do minimal changes otherwise it gets + // confusing. + + dc.Punycode() +} + +// GetDomainCorrections gets existing records, diffs them against existing, and returns corrections. +func (client *providerClient) GenerateDomainCorrections(dc *models.DomainConfig, existing models.Records) ([]*models.Correction, error) { + + // Read foundRecords: + foundRecords, err := client.GetZoneRecords(dc.Name) + if err != nil { + return nil, fmt.Errorf("c.GetDNSZoneRecords(%v) failed: %v", dc.Name, err) + } + + // Normalize + models.PostProcessRecords(foundRecords) + //txtutil.SplitSingleLongTxt(dc.Records) // Autosplit long TXT records + + differ := diff.New(dc) + _, creates, dels, modifications, err := differ.IncrementalDiff(foundRecords) + if err != nil { + return nil, err + } + + // How to generate corrections? + + // (1) Most providers take individual deletes, creates, and + // modifications: + + // // Generate changes. + // corrections := []*models.Correction{} + // for _, del := range dels { + // corrections = append(corrections, client.deleteRec(client.dnsserver, dc.Name, del)) + // } + // for _, cre := range creates { + // corrections = append(corrections, client.createRec(client.dnsserver, dc.Name, cre)...) + // } + // for _, m := range modifications { + // corrections = append(corrections, client.modifyRec(client.dnsserver, dc.Name, m)) + // } + // return corrections, nil + + // (2) Some providers upload the entire zone every time. Look at + // GetDomainCorrections for BIND and NAMECHEAP for inspiration. + + // (3) Others do something entirely different. Like CSCGlobal: + + // CSCGlobal has a unique API. A list of edits is sent in one API + // call. Edits aren't permitted if an existing edit is being + // processed. Therefore, before we do an edit we block until the + // previous edit is done executing. + + var edits []zoneResourceRecordEdit + var descriptions []string + for _, del := range dels { + edits = append(edits, makePurge(dc.Name, del)) + descriptions = append(descriptions, del.String()) + } + for _, cre := range creates { + edits = append(edits, makeAdd(dc.Name, cre)) + descriptions = append(descriptions, cre.String()) + } + for _, m := range modifications { + edits = append(edits, makeEdit(dc.Name, m)) + descriptions = append(descriptions, m.String()) + } + corrections := []*models.Correction{} + if len(edits) > 0 { + c := &models.Correction{ + Msg: "\t" + strings.Join(descriptions, "\n\t"), + F: func() error { + // CSCGlobal's API only permits one pending update at a time. + // Therefore we block until any outstanding updates are done. + // We also clear out any failures, since (and I can't believe + // I'm writing this) any time something fails, the failure has + // to be cleared out with an additional API call. + + err := client.clearRequests(dc.Name) + if err != nil { + return err + } + return client.sendZoneEditRequest(dc.Name, edits) + }, + } + corrections = append(corrections, c) + } + return corrections, nil +} + +func makePurge(domainname string, cor diff.Correlation) zoneResourceRecordEdit { + var existingTarget string + + switch cor.Existing.Type { + case "TXT": + existingTarget = strings.Join(cor.Existing.TxtStrings, "") + default: + existingTarget = cor.Existing.GetTargetField() + } + + zer := zoneResourceRecordEdit{ + Action: "PURGE", + RecordType: cor.Existing.Type, + CurrentKey: cor.Existing.Name, + CurrentValue: existingTarget, + } + + if cor.Existing.Type == "CAA" { + var tagValue = cor.Existing.CaaTag + //fmt.Printf("DEBUG: CAA TAG = %q\n", tagValue) + zer.CurrentTag = &tagValue + } + + return zer +} + +func makeAdd(domainname string, cre diff.Correlation) zoneResourceRecordEdit { + rec := cre.Desired + + var recTarget string + switch rec.Type { + case "TXT": + recTarget = strings.Join(rec.TxtStrings, "") + default: + recTarget = rec.GetTargetField() + } + + zer := zoneResourceRecordEdit{ + Action: "ADD", + RecordType: rec.Type, + NewKey: rec.Name, + NewValue: recTarget, + NewTTL: rec.TTL, + } + + switch rec.Type { + case "CAA": + var tagValue = rec.CaaTag + var flagValue = rec.CaaFlag + zer.NewTag = &tagValue + zer.NewFlag = &flagValue + case "MX": + zer.NewPriority = rec.MxPreference + case "SRV": + zer.NewPriority = rec.SrvPriority + zer.NewWeight = rec.SrvWeight + zer.NewPort = rec.SrvPort + case "TXT": + zer.NewValue = strings.Join(rec.TxtStrings, "") + default: // "A", "CNAME", "NS" + // Nothing to do. + } + + return zer +} + +func makeEdit(domainname string, m diff.Correlation) zoneResourceRecordEdit { + old, rec := m.Existing, m.Desired + // TODO: Assert that old.Type == rec.Type + // TODO: Assert that old.Name == rec.Name + + var oldTarget, recTarget string + switch old.Type { + case "TXT": + oldTarget = strings.Join(old.TxtStrings, "") + recTarget = strings.Join(rec.TxtStrings, "") + default: + oldTarget = old.GetTargetField() + recTarget = rec.GetTargetField() + } + + zer := zoneResourceRecordEdit{ + Action: "EDIT", + RecordType: old.Type, + CurrentKey: old.Name, + CurrentValue: oldTarget, + } + if oldTarget != recTarget { + zer.NewValue = recTarget + } + if old.TTL != rec.TTL { + zer.NewTTL = rec.TTL + } + + switch old.Type { + case "CAA": + var tagValue = old.CaaTag + zer.CurrentTag = &tagValue + if old.CaaTag != rec.CaaTag { + zer.NewTag = &(rec.CaaTag) + } + if old.CaaFlag != rec.CaaFlag { + zer.NewFlag = &(rec.CaaFlag) + } + case "MX": + if old.MxPreference != rec.MxPreference { + zer.NewPriority = rec.MxPreference + } + case "SRV": + zer.NewWeight = rec.SrvWeight + zer.NewPort = rec.SrvPort + zer.NewPriority = rec.SrvPriority + default: // "A", "CNAME", "NS", "TXT" + // Nothing to do. + } + + return zer +} diff --git a/providers/cscglobal/listzones.go b/providers/cscglobal/listzones.go new file mode 100644 index 000000000..dfd1a2c3c --- /dev/null +++ b/providers/cscglobal/listzones.go @@ -0,0 +1,7 @@ +package cscglobal + +// ListZones returns all the zones in an account +func (client *providerClient) ListZones() ([]string, error) { + + return client.getDomains() +} diff --git a/providers/cscglobal/registrar.go b/providers/cscglobal/registrar.go new file mode 100644 index 000000000..c79fe42df --- /dev/null +++ b/providers/cscglobal/registrar.go @@ -0,0 +1,42 @@ +package cscglobal + +import ( + "fmt" + "sort" + "strings" + + "github.com/StackExchange/dnscontrol/v3/models" +) + +// GetRegistrarCorrections gathers corrections that would being n to match dc. +func (client *providerClient) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + nss, err := client.getNameservers(dc.Name) + if err != nil { + return nil, err + } + foundNameservers := strings.Join(nss, ",") + + expected := []string{} + for _, ns := range dc.Nameservers { + if ns.Name[len(ns.Name)-1] == '.' { + // When this code was written ns.Name never included a single trailing dot. + // If that changes, the code should change too. + return nil, fmt.Errorf("name server includes a trailing dot, has the API changed?") + } + expected = append(expected, ns.Name) + } + sort.Strings(expected) + expectedNameservers := strings.Join(expected, ",") + + if foundNameservers != expectedNameservers { + return []*models.Correction{ + { + Msg: fmt.Sprintf("Update nameservers %s -> %s", foundNameservers, expectedNameservers), + F: func() error { + return client.updateNameservers(expected, dc.Name) + }, + }, + }, nil + } + return nil, nil +} diff --git a/providers/providers.go b/providers/providers.go index bbcd45a48..e30ae34f8 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -82,7 +82,7 @@ func CreateRegistrar(rType string, config map[string]string) (Registrar, error) initer, ok := RegistrarTypes[rType] if !ok { - return nil, fmt.Errorf("No such registrar type: %q", rType) + return nil, fmt.Errorf("no such registrar type: %q", rType) } return initer(config) } @@ -97,7 +97,7 @@ func CreateDNSProvider(providerTypeName string, config map[string]string, meta j p, ok := DNSProviderTypes[providerTypeName] if !ok { - return nil, fmt.Errorf("No such DNS service provider: %q", providerTypeName) + return nil, fmt.Errorf("no such DNS service provider: %q", providerTypeName) } return p.Initializer(config, meta) } @@ -135,7 +135,7 @@ func beCompatible(n string, config map[string]string) (string, error) { func AuditRecords(dType string, rcs models.Records) error { p, ok := DNSProviderTypes[dType] if !ok { - return fmt.Errorf("Unknown DNS service provider type: %q", dType) + return fmt.Errorf("unknown DNS service provider type: %q", dType) } if p.RecordAuditor == nil { return fmt.Errorf("DNS service provider type %q has no RecordAuditor", dType)