github.com/kurthockenbury/dnscontrol@v0.2.8/providers/cloudflare/cloudflareProvider.go (about) 1 package cloudflare 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "log" 7 "net" 8 "strings" 9 "time" 10 11 "github.com/StackExchange/dnscontrol/models" 12 "github.com/StackExchange/dnscontrol/pkg/printer" 13 "github.com/StackExchange/dnscontrol/pkg/transform" 14 "github.com/StackExchange/dnscontrol/providers" 15 "github.com/StackExchange/dnscontrol/providers/diff" 16 "github.com/miekg/dns/dnsutil" 17 "github.com/pkg/errors" 18 ) 19 20 /* 21 22 Cloudflare API DNS provider: 23 24 Info required in `creds.json`: 25 - apikey 26 - apiuser 27 - accountid (optional) 28 - accountname (optional) 29 30 Record level metadata available: 31 - cloudflare_proxy ("on", "off", or "full") 32 33 Domain level metadata available: 34 - cloudflare_proxy_default ("on", "off", or "full") 35 36 Provider level metadata available: 37 - ip_conversions 38 */ 39 40 var features = providers.DocumentationNotes{ 41 providers.CanUseAlias: providers.Can("CF automatically flattens CNAME records into A records dynamically"), 42 providers.CanUseCAA: providers.Can(), 43 providers.CanUseSRV: providers.Can(), 44 providers.DocCreateDomains: providers.Can(), 45 providers.DocDualHost: providers.Cannot("Cloudflare will not work well in situations where it is not the only DNS server"), 46 providers.DocOfficiallySupported: providers.Can(), 47 } 48 49 func init() { 50 providers.RegisterDomainServiceProviderType("CLOUDFLAREAPI", newCloudflare, features) 51 providers.RegisterCustomRecordType("CF_REDIRECT", "CLOUDFLAREAPI", "") 52 providers.RegisterCustomRecordType("CF_TEMP_REDIRECT", "CLOUDFLAREAPI", "") 53 } 54 55 // CloudflareApi is the handle for API calls. 56 type CloudflareApi struct { 57 ApiKey string `json:"apikey"` 58 ApiUser string `json:"apiuser"` 59 AccountID string `json:"accountid"` 60 AccountName string `json:"accountname"` 61 domainIndex map[string]string 62 nameservers map[string][]string 63 ipConversions []transform.IpConversion 64 ignoredLabels []string 65 manageRedirects bool 66 } 67 68 func labelMatches(label string, matches []string) bool { 69 printer.Debugf("DEBUG: labelMatches(%#v, %#v)\n", label, matches) 70 for _, tst := range matches { 71 if label == tst { 72 return true 73 } 74 } 75 return false 76 } 77 78 // GetNameservers returns the nameservers for a domain. 79 func (c *CloudflareApi) GetNameservers(domain string) ([]*models.Nameserver, error) { 80 if c.domainIndex == nil { 81 if err := c.fetchDomainList(); err != nil { 82 return nil, err 83 } 84 } 85 ns, ok := c.nameservers[domain] 86 if !ok { 87 return nil, errors.Errorf("Nameservers for %s not found in cloudflare account", domain) 88 } 89 return models.StringsToNameservers(ns), nil 90 } 91 92 // GetDomainCorrections returns a list of corrections to update a domain. 93 func (c *CloudflareApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { 94 if c.domainIndex == nil { 95 if err := c.fetchDomainList(); err != nil { 96 return nil, err 97 } 98 } 99 id, ok := c.domainIndex[dc.Name] 100 if !ok { 101 return nil, errors.Errorf("%s not listed in zones for cloudflare account", dc.Name) 102 } 103 if err := c.preprocessConfig(dc); err != nil { 104 return nil, err 105 } 106 records, err := c.getRecordsForDomain(id, dc.Name) 107 if err != nil { 108 return nil, err 109 } 110 for i := len(records) - 1; i >= 0; i-- { 111 rec := records[i] 112 // Delete ignore labels 113 if labelMatches(dnsutil.TrimDomainName(rec.Original.(*cfRecord).Name, dc.Name), c.ignoredLabels) { 114 printer.Debugf("ignored_label: %s\n", rec.Original.(*cfRecord).Name) 115 records = append(records[:i], records[i+1:]...) 116 } 117 } 118 if c.manageRedirects { 119 prs, err := c.getPageRules(id, dc.Name) 120 if err != nil { 121 return nil, err 122 } 123 records = append(records, prs...) 124 } 125 for _, rec := range dc.Records { 126 if rec.Type == "ALIAS" { 127 rec.Type = "CNAME" 128 } 129 if labelMatches(rec.GetLabel(), c.ignoredLabels) { 130 log.Fatalf("FATAL: dnsconfig contains label that matches ignored_labels: %#v is in %v)\n", rec.GetLabel(), c.ignoredLabels) 131 } 132 } 133 checkNSModifications(dc) 134 135 // Normalize 136 models.PostProcessRecords(records) 137 138 differ := diff.New(dc, getProxyMetadata) 139 _, create, del, mod := differ.IncrementalDiff(records) 140 corrections := []*models.Correction{} 141 142 for _, d := range del { 143 ex := d.Existing 144 if ex.Type == "PAGE_RULE" { 145 corrections = append(corrections, &models.Correction{ 146 Msg: d.String(), 147 F: func() error { return c.deletePageRule(ex.Original.(*pageRule).ID, id) }, 148 }) 149 150 } else { 151 corrections = append(corrections, c.deleteRec(ex.Original.(*cfRecord), id)) 152 } 153 } 154 for _, d := range create { 155 des := d.Desired 156 if des.Type == "PAGE_RULE" { 157 corrections = append(corrections, &models.Correction{ 158 Msg: d.String(), 159 F: func() error { return c.createPageRule(id, des.GetTargetField()) }, 160 }) 161 } else { 162 corrections = append(corrections, c.createRec(des, id)...) 163 } 164 } 165 166 for _, d := range mod { 167 rec := d.Desired 168 ex := d.Existing 169 if rec.Type == "PAGE_RULE" { 170 corrections = append(corrections, &models.Correction{ 171 Msg: d.String(), 172 F: func() error { return c.updatePageRule(ex.Original.(*pageRule).ID, id, rec.GetTargetField()) }, 173 }) 174 } else { 175 e := ex.Original.(*cfRecord) 176 proxy := e.Proxiable && rec.Metadata[metaProxy] != "off" 177 corrections = append(corrections, &models.Correction{ 178 Msg: d.String(), 179 F: func() error { return c.modifyRecord(id, e.ID, proxy, rec) }, 180 }) 181 } 182 } 183 return corrections, nil 184 } 185 186 func checkNSModifications(dc *models.DomainConfig) { 187 newList := make([]*models.RecordConfig, 0, len(dc.Records)) 188 for _, rec := range dc.Records { 189 if rec.Type == "NS" && rec.GetLabelFQDN() == dc.Name { 190 if !strings.HasSuffix(rec.GetTargetField(), ".ns.cloudflare.com.") { 191 printer.Warnf("cloudflare does not support modifying NS records on base domain. %s will not be added.\n", rec.GetTargetField()) 192 } 193 continue 194 } 195 newList = append(newList, rec) 196 } 197 dc.Records = newList 198 } 199 200 const ( 201 metaProxy = "cloudflare_proxy" 202 metaProxyDefault = metaProxy + "_default" 203 metaOriginalIP = "original_ip" // TODO(tlim): Unclear what this means. 204 metaIPConversions = "ip_conversions" // TODO(tlim): Rename to obscure_rules. 205 ) 206 207 func checkProxyVal(v string) (string, error) { 208 v = strings.ToLower(v) 209 if v != "on" && v != "off" && v != "full" { 210 return "", errors.Errorf("Bad metadata value for cloudflare_proxy: '%s'. Use on/off/full", v) 211 } 212 return v, nil 213 } 214 215 func (c *CloudflareApi) preprocessConfig(dc *models.DomainConfig) error { 216 217 // Determine the default proxy setting. 218 var defProxy string 219 var err error 220 if defProxy = dc.Metadata[metaProxyDefault]; defProxy == "" { 221 defProxy = "off" 222 } else { 223 defProxy, err = checkProxyVal(defProxy) 224 if err != nil { 225 return err 226 } 227 } 228 229 currentPrPrio := 1 230 231 // Normalize the proxy setting for each record. 232 // A and CNAMEs: Validate. If null, set to default. 233 // else: Make sure it wasn't set. Set to default. 234 // iterate backwards so first defined page rules have highest priority 235 for i := len(dc.Records) - 1; i >= 0; i-- { 236 rec := dc.Records[i] 237 if rec.Metadata == nil { 238 rec.Metadata = map[string]string{} 239 } 240 if rec.TTL == 0 || rec.TTL == 300 { 241 rec.TTL = 1 242 } 243 if rec.TTL != 1 && rec.TTL < 120 { 244 rec.TTL = 120 245 } 246 if rec.Type != "A" && rec.Type != "CNAME" && rec.Type != "AAAA" && rec.Type != "ALIAS" { 247 if rec.Metadata[metaProxy] != "" { 248 return errors.Errorf("cloudflare_proxy set on %v record: %#v cloudflare_proxy=%#v", rec.Type, rec.GetLabel(), rec.Metadata[metaProxy]) 249 } 250 // Force it to off. 251 rec.Metadata[metaProxy] = "off" 252 } else { 253 if val := rec.Metadata[metaProxy]; val == "" { 254 rec.Metadata[metaProxy] = defProxy 255 } else { 256 val, err := checkProxyVal(val) 257 if err != nil { 258 return err 259 } 260 rec.Metadata[metaProxy] = val 261 } 262 } 263 // CF_REDIRECT record types. Encode target as $FROM,$TO,$PRIO,$CODE 264 if rec.Type == "CF_REDIRECT" || rec.Type == "CF_TEMP_REDIRECT" { 265 if !c.manageRedirects { 266 return errors.Errorf("you must add 'manage_redirects: true' metadata to cloudflare provider to use CF_REDIRECT records") 267 } 268 parts := strings.Split(rec.GetTargetField(), ",") 269 if len(parts) != 2 { 270 return errors.Errorf("Invalid data specified for cloudflare redirect record") 271 } 272 code := 301 273 if rec.Type == "CF_TEMP_REDIRECT" { 274 code = 302 275 } 276 rec.SetTarget(fmt.Sprintf("%s,%d,%d", rec.GetTargetField(), currentPrPrio, code)) 277 currentPrPrio++ 278 rec.Type = "PAGE_RULE" 279 } 280 } 281 282 // look for ip conversions and transform records 283 for _, rec := range dc.Records { 284 if rec.Type != "A" { 285 continue 286 } 287 // only transform "full" 288 if rec.Metadata[metaProxy] != "full" { 289 continue 290 } 291 ip := net.ParseIP(rec.GetTargetField()) 292 if ip == nil { 293 return errors.Errorf("%s is not a valid ip address", rec.GetTargetField()) 294 } 295 newIP, err := transform.TransformIP(ip, c.ipConversions) 296 if err != nil { 297 return err 298 } 299 rec.Metadata[metaOriginalIP] = rec.GetTargetField() 300 rec.SetTarget(newIP.String()) 301 } 302 303 return nil 304 } 305 306 func newCloudflare(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { 307 api := &CloudflareApi{} 308 api.ApiUser, api.ApiKey = m["apiuser"], m["apikey"] 309 // check api keys from creds json file 310 if api.ApiKey == "" || api.ApiUser == "" { 311 return nil, errors.Errorf("cloudflare apikey and apiuser must be provided") 312 } 313 314 // Check account data if set 315 api.AccountID, api.AccountName = m["accountid"], m["accountname"] 316 if (api.AccountID != "" && api.AccountName == "") || (api.AccountID == "" && api.AccountName != "") { 317 return nil, errors.Errorf("either both cloudflare accountid and accountname must be provided or neither") 318 } 319 320 err := api.fetchDomainList() 321 if err != nil { 322 return nil, err 323 } 324 325 if len(metadata) > 0 { 326 parsedMeta := &struct { 327 IPConversions string `json:"ip_conversions"` 328 IgnoredLabels []string `json:"ignored_labels"` 329 ManageRedirects bool `json:"manage_redirects"` 330 }{} 331 err := json.Unmarshal([]byte(metadata), parsedMeta) 332 if err != nil { 333 return nil, err 334 } 335 api.manageRedirects = parsedMeta.ManageRedirects 336 // ignored_labels: 337 for _, l := range parsedMeta.IgnoredLabels { 338 api.ignoredLabels = append(api.ignoredLabels, l) 339 } 340 if len(api.ignoredLabels) > 0 { 341 printer.Warnf("Cloudflare 'ignored_labels' configuration is deprecated and might be removed. Please use the IGNORE domain directive to achieve the same effect.\n") 342 } 343 // parse provider level metadata 344 if len(parsedMeta.IPConversions) > 0 { 345 api.ipConversions, err = transform.DecodeTransformTable(parsedMeta.IPConversions) 346 if err != nil { 347 return nil, err 348 } 349 } 350 } 351 return api, nil 352 } 353 354 // Used on the "existing" records. 355 type cfRecData struct { 356 Name string `json:"name"` 357 Target string `json:"target"` 358 Service string `json:"service"` // SRV 359 Proto string `json:"proto"` // SRV 360 Priority uint16 `json:"priority"` // SRV 361 Weight uint16 `json:"weight"` // SRV 362 Port uint16 `json:"port"` // SRV 363 Tag string `json:"tag"` // CAA 364 Flags uint8 `json:"flags"` // CAA 365 Value string `json:"value"` // CAA 366 } 367 368 type cfRecord struct { 369 ID string `json:"id"` 370 Type string `json:"type"` 371 Name string `json:"name"` 372 Content string `json:"content"` 373 Proxiable bool `json:"proxiable"` 374 Proxied bool `json:"proxied"` 375 TTL uint32 `json:"ttl"` 376 Locked bool `json:"locked"` 377 ZoneID string `json:"zone_id"` 378 ZoneName string `json:"zone_name"` 379 CreatedOn time.Time `json:"created_on"` 380 ModifiedOn time.Time `json:"modified_on"` 381 Data *cfRecData `json:"data"` 382 Priority json.Number `json:"priority"` 383 } 384 385 func (c *cfRecord) nativeToRecord(domain string) *models.RecordConfig { 386 // normalize cname,mx,ns records with dots to be consistent with our config format. 387 if c.Type == "CNAME" || c.Type == "MX" || c.Type == "NS" || c.Type == "SRV" { 388 c.Content = dnsutil.AddOrigin(c.Content+".", domain) 389 } 390 391 rc := &models.RecordConfig{ 392 TTL: c.TTL, 393 Original: c, 394 } 395 rc.SetLabelFromFQDN(c.Name, domain) 396 switch rType := c.Type; rType { // #rtype_variations 397 case "MX": 398 var priority uint16 399 if c.Priority == "" { 400 priority = 0 401 } else { 402 if p, err := c.Priority.Int64(); err != nil { 403 panic(errors.Wrap(err, "error decoding priority from cloudflare record")) 404 } else { 405 priority = uint16(p) 406 } 407 } 408 if err := rc.SetTargetMX(priority, c.Content); err != nil { 409 panic(errors.Wrap(err, "unparsable MX record received from cloudflare")) 410 } 411 case "SRV": 412 data := *c.Data 413 if err := rc.SetTargetSRV(data.Priority, data.Weight, data.Port, 414 dnsutil.AddOrigin(data.Target+".", domain)); err != nil { 415 panic(errors.Wrap(err, "unparsable SRV record received from cloudflare")) 416 } 417 default: // "A", "AAAA", "ANAME", "CAA", "CNAME", "NS", "PTR", "TXT" 418 if err := rc.PopulateFromString(rType, c.Content, domain); err != nil { 419 panic(errors.Wrap(err, "unparsable record received from cloudflare")) 420 } 421 } 422 423 return rc 424 } 425 426 func getProxyMetadata(r *models.RecordConfig) map[string]string { 427 if r.Type != "A" && r.Type != "AAAA" && r.Type != "CNAME" { 428 return nil 429 } 430 proxied := false 431 if r.Original != nil { 432 proxied = r.Original.(*cfRecord).Proxied 433 } else { 434 proxied = r.Metadata[metaProxy] != "off" 435 } 436 return map[string]string{ 437 "proxy": fmt.Sprint(proxied), 438 } 439 } 440 441 // EnsureDomainExists returns an error of domain does not exist. 442 func (c *CloudflareApi) EnsureDomainExists(domain string) error { 443 if _, ok := c.domainIndex[domain]; ok { 444 return nil 445 } 446 var id string 447 id, err := c.createZone(domain) 448 fmt.Printf("Added zone for %s to Cloudflare account: %s\n", domain, id) 449 return err 450 }