mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2024-05-11 05:55:12 +00:00
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
560 lines
15 KiB
Go
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"`
|
|
}
|