1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00
Files
stackexchange-dnscontrol/providers/cloudflare/rest.go
Patrick Gaskin 70ce16ff23 Fix handling of SRV records with no target (indicated by ".")
According to the RFC, the way to indicate that a SRV has no target is to set the target to ".".  Some providers do not handle this, or the API returns "" instead of ".".  This situation is now tested in the integration tests and all providers (that support this) have been fixed.



* Cloudflare: Fix decoding empty SRV target (fixes #561)

SRV records with empty (".") targets are now returned as false by
the API, which breaks Unmarshaling it into a string.

* Use custom type for Cloudflare SRV target

Rewrote the SRV target decoding to use a custom type for (un)marshaling, as
Cloudflare returns false for null targets, but it requires a single period
for giving it one. The target code has also been made more flexible to future
API changes with additional normalization.

This has been tested with record creation, deletion, and update and works
as of 2019-11-05.

* DigitalOcean: Fix target FQDN for null targets

Without this, dnscontrol thinks an update is needed (.. != .) even
when the SRV target is correct.

* DNSimple: Fix parsing of null SRV target

DNSimple only returns two fields when the target is null.

* NameDotCom: Add note about not supporting null SRV targets, skip test

* DNSimple: Do not append a . unless we have all three parts

Signed-off-by: Amelia Aronsohn <squirrel@wearing.black>

* Regenerated provider matrix
2019-11-14 11:25:20 -05:00

560 lines
15 KiB
Go

package cloudflare
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"strings"
"time"
"github.com/StackExchange/dnscontrol/models"
"github.com/pkg/errors"
)
const (
baseURL = "https://api.cloudflare.com/client/v4/"
zonesURL = baseURL + "zones/"
recordsURL = zonesURL + "%s/dns_records/"
pageRulesURL = zonesURL + "%s/pagerules/"
singlePageRuleURL = pageRulesURL + "%s"
singleRecordURL = recordsURL + "%s"
)
// get list of domains for account. Cache so the ids can be looked up from domain name
func (c *CloudflareApi) fetchDomainList() error {
c.domainIndex = map[string]string{}
c.nameservers = map[string][]string{}
page := 1
for {
zr := &zoneResponse{}
url := fmt.Sprintf("%s?page=%d&per_page=50", zonesURL, page)
if err := c.get(url, zr); err != nil {
return errors.Errorf("Error fetching domain list from cloudflare: %s", err)
}
if !zr.Success {
return errors.Errorf("Error fetching domain list from cloudflare: %s", stringifyErrors(zr.Errors))
}
for _, zone := range zr.Result {
c.domainIndex[zone.Name] = zone.ID
for _, ns := range zone.Nameservers {
c.nameservers[zone.Name] = append(c.nameservers[zone.Name], ns)
}
}
ri := zr.ResultInfo
if len(zr.Result) == 0 || ri.Page*ri.PerPage >= ri.TotalCount {
break
}
page++
}
return nil
}
// get all records for a domain
func (c *CloudflareApi) getRecordsForDomain(id string, domain string) ([]*models.RecordConfig, error) {
url := fmt.Sprintf(recordsURL, id)
page := 1
records := []*models.RecordConfig{}
for {
reqURL := fmt.Sprintf("%s?page=%d&per_page=100", url, page)
var data recordsResponse
if err := c.get(reqURL, &data); err != nil {
return nil, errors.Errorf("Error fetching record list from cloudflare: %s", err)
}
if !data.Success {
return nil, errors.Errorf("Error fetching record list cloudflare: %s", stringifyErrors(data.Errors))
}
for _, rec := range data.Result {
// fmt.Printf("REC: %+v\n", rec)
records = append(records, rec.nativeToRecord(domain))
}
ri := data.ResultInfo
if len(data.Result) == 0 || ri.Page*ri.PerPage >= ri.TotalCount {
break
}
page++
}
// fmt.Printf("DEBUG REORDS=%v\n", records)
return records, nil
}
// create a correction to delete a record
func (c *CloudflareApi) deleteRec(rec *cfRecord, domainID string) *models.Correction {
return &models.Correction{
Msg: fmt.Sprintf("DELETE record: %s %s %d %s (id=%s)", rec.Name, rec.Type, rec.TTL, rec.Content, rec.ID),
F: func() error {
endpoint := fmt.Sprintf(singleRecordURL, domainID, rec.ID)
req, err := http.NewRequest("DELETE", endpoint, nil)
if err != nil {
return err
}
c.setHeaders(req)
_, err = handleActionResponse(http.DefaultClient.Do(req))
return err
},
}
}
func (c *CloudflareApi) createZone(domainName string) (string, error) {
type createZone struct {
Name string `json:"name"`
Account struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"account"`
}
var id string
cz := &createZone{
Name: domainName}
if c.AccountID != "" || c.AccountName != "" {
cz.Account.ID = c.AccountID
cz.Account.Name = c.AccountName
}
buf := &bytes.Buffer{}
encoder := json.NewEncoder(buf)
if err := encoder.Encode(cz); err != nil {
return "", err
}
req, err := http.NewRequest("POST", zonesURL, buf)
if err != nil {
return "", err
}
c.setHeaders(req)
id, err = handleActionResponse(http.DefaultClient.Do(req))
return id, err
}
func cfSrvData(rec *models.RecordConfig) *cfRecData {
serverParts := strings.Split(rec.GetLabelFQDN(), ".")
c := &cfRecData{
Service: serverParts[0],
Proto: serverParts[1],
Name: strings.Join(serverParts[2:], "."),
Port: rec.SrvPort,
Priority: rec.SrvPriority,
Weight: rec.SrvWeight,
}
c.Target = cfTarget(rec.GetTargetField())
return c
}
func cfCaaData(rec *models.RecordConfig) *cfRecData {
return &cfRecData{
Tag: rec.CaaTag,
Flags: rec.CaaFlag,
Value: rec.GetTargetField(),
}
}
func cfTlsaData(rec *models.RecordConfig) *cfRecData {
return &cfRecData{
Usage: rec.TlsaUsage,
Selector: rec.TlsaSelector,
Matching_Type: rec.TlsaMatchingType,
Certificate: rec.GetTargetField(),
}
}
func cfSshfpData(rec *models.RecordConfig) *cfRecData {
return &cfRecData{
Algorithm: rec.SshfpAlgorithm,
Hash_Type: rec.SshfpFingerprint,
Fingerprint: rec.GetTargetField(),
}
}
func (c *CloudflareApi) createRec(rec *models.RecordConfig, domainID string) []*models.Correction {
type createRecord struct {
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
TTL uint32 `json:"ttl"`
Priority uint16 `json:"priority"`
Data *cfRecData `json:"data"`
}
var id string
content := rec.GetTargetField()
if rec.Metadata[metaOriginalIP] != "" {
content = rec.Metadata[metaOriginalIP]
}
prio := ""
if rec.Type == "MX" {
prio = fmt.Sprintf(" %d ", rec.MxPreference)
}
arr := []*models.Correction{{
Msg: fmt.Sprintf("CREATE record: %s %s %d%s %s", rec.GetLabel(), rec.Type, rec.TTL, prio, content),
F: func() error {
cf := &createRecord{
Name: rec.GetLabel(),
Type: rec.Type,
TTL: rec.TTL,
Content: content,
Priority: rec.MxPreference,
}
if rec.Type == "SRV" {
cf.Data = cfSrvData(rec)
cf.Name = rec.GetLabelFQDN()
} else if rec.Type == "CAA" {
cf.Data = cfCaaData(rec)
cf.Name = rec.GetLabelFQDN()
cf.Content = ""
} else if rec.Type == "TLSA" {
cf.Data = cfTlsaData(rec)
cf.Name = rec.GetLabelFQDN()
} else if rec.Type == "SSHFP" {
cf.Data = cfSshfpData(rec)
cf.Name = rec.GetLabelFQDN()
}
endpoint := fmt.Sprintf(recordsURL, domainID)
buf := &bytes.Buffer{}
encoder := json.NewEncoder(buf)
if err := encoder.Encode(cf); err != nil {
return err
}
req, err := http.NewRequest("POST", endpoint, buf)
if err != nil {
return err
}
c.setHeaders(req)
id, err = handleActionResponse(http.DefaultClient.Do(req))
return err
},
}}
if rec.Metadata[metaProxy] != "off" {
arr = append(arr, &models.Correction{
Msg: fmt.Sprintf("ACTIVATE PROXY for new record %s %s %d %s", rec.GetLabel(), rec.Type, rec.TTL, rec.GetTargetField()),
F: func() error { return c.modifyRecord(domainID, id, true, rec) },
})
}
return arr
}
func (c *CloudflareApi) modifyRecord(domainID, recID string, proxied bool, rec *models.RecordConfig) error {
if domainID == "" || recID == "" {
return errors.Errorf("cannot modify record if domain or record id are empty")
}
type record struct {
ID string `json:"id"`
Proxied bool `json:"proxied"`
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
Priority uint16 `json:"priority"`
TTL uint32 `json:"ttl"`
Data *cfRecData `json:"data"`
}
r := record{
ID: recID,
Proxied: proxied,
Name: rec.GetLabel(),
Type: rec.Type,
Content: rec.GetTargetField(),
Priority: rec.MxPreference,
TTL: rec.TTL,
Data: nil,
}
if rec.Type == "SRV" {
r.Data = cfSrvData(rec)
r.Name = rec.GetLabelFQDN()
} else if rec.Type == "CAA" {
r.Data = cfCaaData(rec)
r.Name = rec.GetLabelFQDN()
r.Content = ""
} else if rec.Type == "TLSA" {
r.Data = cfTlsaData(rec)
r.Name = rec.GetLabelFQDN()
} else if rec.Type == "SSHFP" {
r.Data = cfSshfpData(rec)
r.Name = rec.GetLabelFQDN()
}
endpoint := fmt.Sprintf(singleRecordURL, domainID, recID)
buf := &bytes.Buffer{}
encoder := json.NewEncoder(buf)
if err := encoder.Encode(r); err != nil {
return err
}
req, err := http.NewRequest("PUT", endpoint, buf)
if err != nil {
return err
}
c.setHeaders(req)
_, err = handleActionResponse(http.DefaultClient.Do(req))
return err
}
// change universal ssl state
func (c *CloudflareApi) changeUniversalSSL(domainID string, state bool) error {
type setUniversalSSL struct {
Enabled bool `json:"enabled"`
}
us := &setUniversalSSL{
Enabled: state,
}
// create json
buf := &bytes.Buffer{}
encoder := json.NewEncoder(buf)
if err := encoder.Encode(us); err != nil {
return err
}
// send request.
endpoint := fmt.Sprintf(zonesURL+"%s/ssl/universal/settings", domainID)
req, err := http.NewRequest("PATCH", endpoint, buf)
if err != nil {
return err
}
c.setHeaders(req)
_, err = handleActionResponse(http.DefaultClient.Do(req))
return err
}
// change universal ssl state
func (c *CloudflareApi) getUniversalSSL(domainID string) (bool, error) {
type universalSSLResponse struct {
Success bool `json:"success"`
Errors []interface{} `json:"errors"`
Messages []interface{} `json:"messages"`
Result struct {
Enabled bool `json:"enabled"`
} `json:"result"`
}
// send request.
endpoint := fmt.Sprintf(zonesURL+"%s/ssl/universal/settings", domainID)
var result universalSSLResponse
err := c.get(endpoint, &result)
if err != nil {
return true, err
}
return result.Result.Enabled, err
}
// common error handling for all action responses
func handleActionResponse(resp *http.Response, err error) (id string, e error) {
if err != nil {
return "", err
}
defer resp.Body.Close()
result := &basicResponse{}
decoder := json.NewDecoder(resp.Body)
if err = decoder.Decode(result); err != nil {
return "", errors.Errorf("Unknown error. Status code: %d", resp.StatusCode)
}
if resp.StatusCode != 200 {
return "", errors.Errorf(stringifyErrors(result.Errors))
}
return result.Result.ID, nil
}
func (c *CloudflareApi) setHeaders(req *http.Request) {
if len(c.ApiToken) > 0 {
req.Header.Set("Authorization", "Bearer "+c.ApiToken)
} else {
req.Header.Set("X-Auth-Key", c.ApiKey)
req.Header.Set("X-Auth-Email", c.ApiUser)
}
}
// generic get handler. makes request and unmarshalls response to given interface
func (c *CloudflareApi) get(endpoint string, target interface{}) error {
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return err
}
c.setHeaders(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
dat, _ := ioutil.ReadAll(resp.Body)
fmt.Println(string(dat))
return errors.Errorf("bad status code from cloudflare: %d not 200", resp.StatusCode)
}
decoder := json.NewDecoder(resp.Body)
return decoder.Decode(target)
}
func (c *CloudflareApi) getPageRules(id string, domain string) ([]*models.RecordConfig, error) {
url := fmt.Sprintf(pageRulesURL, id)
data := pageRuleResponse{}
if err := c.get(url, &data); err != nil {
return nil, errors.Errorf("Error fetching page rule list from cloudflare: %s", err)
}
if !data.Success {
return nil, errors.Errorf("Error fetching page rule list cloudflare: %s", stringifyErrors(data.Errors))
}
recs := []*models.RecordConfig{}
for _, pr := range data.Result {
// only interested in forwarding rules. Lets be very specific, and skip anything else
if len(pr.Actions) != 1 || len(pr.Targets) != 1 {
continue
}
if pr.Actions[0].ID != "forwarding_url" {
continue
}
err := json.Unmarshal([]byte(pr.Actions[0].Value), &pr.ForwardingInfo)
if err != nil {
return nil, err
}
var thisPr = pr
r := &models.RecordConfig{
Type: "PAGE_RULE",
Original: thisPr,
TTL: 1,
}
r.SetLabel("@", domain)
r.SetTarget(fmt.Sprintf("%s,%s,%d,%d", // $FROM,$TO,$PRIO,$CODE
pr.Targets[0].Constraint.Value,
pr.ForwardingInfo.URL,
pr.Priority,
pr.ForwardingInfo.StatusCode))
recs = append(recs, r)
}
return recs, nil
}
func (c *CloudflareApi) deletePageRule(recordID, domainID string) error {
endpoint := fmt.Sprintf(singlePageRuleURL, domainID, recordID)
req, err := http.NewRequest("DELETE", endpoint, nil)
if err != nil {
return err
}
c.setHeaders(req)
_, err = handleActionResponse(http.DefaultClient.Do(req))
return err
}
func (c *CloudflareApi) updatePageRule(recordID, domainID string, target string) error {
if err := c.deletePageRule(recordID, domainID); err != nil {
return err
}
return c.createPageRule(domainID, target)
}
func (c *CloudflareApi) createPageRule(domainID string, target string) error {
endpoint := fmt.Sprintf(pageRulesURL, domainID)
return c.sendPageRule(endpoint, "POST", target)
}
func (c *CloudflareApi) sendPageRule(endpoint, method string, data string) error {
// from to priority code
parts := strings.Split(data, ",")
priority, _ := strconv.Atoi(parts[2])
code, _ := strconv.Atoi(parts[3])
fwdInfo := &pageRuleFwdInfo{
StatusCode: code,
URL: parts[1],
}
dat, _ := json.Marshal(fwdInfo)
pr := &pageRule{
Status: "active",
Priority: priority,
Targets: []pageRuleTarget{
{Target: "url", Constraint: pageRuleConstraint{Operator: "matches", Value: parts[0]}},
},
Actions: []pageRuleAction{
{ID: "forwarding_url", Value: json.RawMessage(dat)},
},
}
buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
if err := enc.Encode(pr); err != nil {
return err
}
req, err := http.NewRequest(method, endpoint, buf)
if err != nil {
return err
}
c.setHeaders(req)
_, err = handleActionResponse(http.DefaultClient.Do(req))
return err
}
func stringifyErrors(errors []interface{}) string {
dat, err := json.Marshal(errors)
if err != nil {
return "???"
}
return string(dat)
}
type recordsResponse struct {
basicResponse
Result []*cfRecord `json:"result"`
ResultInfo pagingInfo `json:"result_info"`
}
type basicResponse struct {
Success bool `json:"success"`
Errors []interface{} `json:"errors"`
Messages []interface{} `json:"messages"`
Result struct {
ID string `json:"id"`
} `json:"result"`
}
type pageRuleResponse struct {
basicResponse
Result []*pageRule `json:"result"`
ResultInfo pagingInfo `json:"result_info"`
}
type pageRule struct {
ID string `json:"id,omitempty"`
Targets []pageRuleTarget `json:"targets"`
Actions []pageRuleAction `json:"actions"`
Priority int `json:"priority"`
Status string `json:"status"`
ModifiedOn time.Time `json:"modified_on,omitempty"`
CreatedOn time.Time `json:"created_on,omitempty"`
ForwardingInfo *pageRuleFwdInfo `json:"-"`
}
type pageRuleTarget struct {
Target string `json:"target"`
Constraint pageRuleConstraint `json:"constraint"`
}
type pageRuleConstraint struct {
Operator string `json:"operator"`
Value string `json:"value"`
}
type pageRuleAction struct {
ID string `json:"id"`
Value json.RawMessage `json:"value"`
}
type pageRuleFwdInfo struct {
URL string `json:"url"`
StatusCode int `json:"status_code"`
}
type zoneResponse struct {
basicResponse
Result []struct {
ID string `json:"id"`
Name string `json:"name"`
Nameservers []string `json:"name_servers"`
} `json:"result"`
ResultInfo pagingInfo `json:"result_info"`
}
type pagingInfo struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Count int `json:"count"`
TotalCount int `json:"total_count"`
}