1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

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
This commit is contained in:
Tom Limoncelli
2022-06-12 16:01:08 -04:00
committed by GitHub
parent 60324bc4f5
commit 752e25471d
17 changed files with 1093 additions and 69 deletions

View File

@@ -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`.

View File

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

2
go.mod
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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?")
fns := providers.DspFuncs{
Initializer: newDsp,
RecordAuditor: AuditRecords,
}
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 c.updateNameservers(expected, dc.Name)
},
},
}, nil
}
return nil, nil
providers.RegisterDomainServiceProviderType("CSCGLOBAL", fns, features)
}

314
providers/cscglobal/dns.go Normal file
View File

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

View File

@@ -0,0 +1,7 @@
package cscglobal
// ListZones returns all the zones in an account
func (client *providerClient) ListZones() ([]string, error) {
return client.getDomains()
}

View File

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

View File

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