dnscontrol/providers/cloudflare/rest.go
2020-04-14 16:49:03 -04:00

557 lines
15 KiB
Go

package cloudflare
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"strings"
"time"
"github.com/StackExchange/dnscontrol/v3/models"
)
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 fmt.Errorf("Error fetching domain list from cloudflare: %s", err)
}
if !zr.Success {
return fmt.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, fmt.Errorf("Error fetching record list from cloudflare: %s", err)
}
if !data.Success {
return nil, fmt.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++
}
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 fmt.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 "", fmt.Errorf("Unknown error. Status code: %d", resp.StatusCode)
}
if resp.StatusCode != 200 {
return "", fmt.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 fmt.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, fmt.Errorf("Error fetching page rule list from cloudflare: %s", err)
}
if !data.Success {
return nil, fmt.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"`
}