sigs.k8s.io/external-dns@v0.14.1/provider/cloudflare/cloudflare.go (about) 1 /* 2 Copyright 2017 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package cloudflare 18 19 import ( 20 "context" 21 "fmt" 22 "os" 23 "strconv" 24 "strings" 25 26 cloudflare "github.com/cloudflare/cloudflare-go" 27 log "github.com/sirupsen/logrus" 28 29 "sigs.k8s.io/external-dns/endpoint" 30 "sigs.k8s.io/external-dns/plan" 31 "sigs.k8s.io/external-dns/provider" 32 "sigs.k8s.io/external-dns/source" 33 ) 34 35 const ( 36 // cloudFlareCreate is a ChangeAction enum value 37 cloudFlareCreate = "CREATE" 38 // cloudFlareDelete is a ChangeAction enum value 39 cloudFlareDelete = "DELETE" 40 // cloudFlareUpdate is a ChangeAction enum value 41 cloudFlareUpdate = "UPDATE" 42 // defaultCloudFlareRecordTTL 1 = automatic 43 defaultCloudFlareRecordTTL = 1 44 ) 45 46 // We have to use pointers to bools now, as the upstream cloudflare-go library requires them 47 // see: https://github.com/cloudflare/cloudflare-go/pull/595 48 49 // proxyEnabled is a pointer to a bool true showing the record should be proxied through cloudflare 50 var proxyEnabled *bool = boolPtr(true) 51 52 // proxyDisabled is a pointer to a bool false showing the record should not be proxied through cloudflare 53 var proxyDisabled *bool = boolPtr(false) 54 55 var recordTypeProxyNotSupported = map[string]bool{ 56 "LOC": true, 57 "MX": true, 58 "NS": true, 59 "SPF": true, 60 "TXT": true, 61 "SRV": true, 62 } 63 64 // cloudFlareDNS is the subset of the CloudFlare API that we actually use. Add methods as required. Signatures must match exactly. 65 type cloudFlareDNS interface { 66 UserDetails(ctx context.Context) (cloudflare.User, error) 67 ZoneIDByName(zoneName string) (string, error) 68 ListZones(ctx context.Context, zoneID ...string) ([]cloudflare.Zone, error) 69 ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) 70 ZoneDetails(ctx context.Context, zoneID string) (cloudflare.Zone, error) 71 ListDNSRecords(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.ListDNSRecordsParams) ([]cloudflare.DNSRecord, *cloudflare.ResultInfo, error) 72 CreateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDNSRecordParams) (cloudflare.DNSRecord, error) 73 DeleteDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, recordID string) error 74 UpdateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDNSRecordParams) error 75 } 76 77 type zoneService struct { 78 service *cloudflare.API 79 } 80 81 func (z zoneService) UserDetails(ctx context.Context) (cloudflare.User, error) { 82 return z.service.UserDetails(ctx) 83 } 84 85 func (z zoneService) ListZones(ctx context.Context, zoneID ...string) ([]cloudflare.Zone, error) { 86 return z.service.ListZones(ctx, zoneID...) 87 } 88 89 func (z zoneService) ZoneIDByName(zoneName string) (string, error) { 90 return z.service.ZoneIDByName(zoneName) 91 } 92 93 func (z zoneService) CreateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDNSRecordParams) (cloudflare.DNSRecord, error) { 94 return z.service.CreateDNSRecord(ctx, rc, rp) 95 } 96 97 func (z zoneService) ListDNSRecords(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.ListDNSRecordsParams) ([]cloudflare.DNSRecord, *cloudflare.ResultInfo, error) { 98 return z.service.ListDNSRecords(ctx, rc, rp) 99 } 100 101 func (z zoneService) UpdateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDNSRecordParams) error { 102 _, err := z.service.UpdateDNSRecord(ctx, rc, rp) 103 return err 104 } 105 106 func (z zoneService) DeleteDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, recordID string) error { 107 return z.service.DeleteDNSRecord(ctx, rc, recordID) 108 } 109 110 func (z zoneService) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { 111 return z.service.ListZonesContext(ctx, opts...) 112 } 113 114 func (z zoneService) ZoneDetails(ctx context.Context, zoneID string) (cloudflare.Zone, error) { 115 return z.service.ZoneDetails(ctx, zoneID) 116 } 117 118 // CloudFlareProvider is an implementation of Provider for CloudFlare DNS. 119 type CloudFlareProvider struct { 120 provider.BaseProvider 121 Client cloudFlareDNS 122 // only consider hosted zones managing domains ending in this suffix 123 domainFilter endpoint.DomainFilter 124 zoneIDFilter provider.ZoneIDFilter 125 proxiedByDefault bool 126 DryRun bool 127 DNSRecordsPerPage int 128 } 129 130 // cloudFlareChange differentiates between ChangActions 131 type cloudFlareChange struct { 132 Action string 133 ResourceRecord cloudflare.DNSRecord 134 } 135 136 // RecordParamsTypes is a typeset of the possible Record Params that can be passed to cloudflare-go library 137 type RecordParamsTypes interface { 138 cloudflare.UpdateDNSRecordParams | cloudflare.CreateDNSRecordParams 139 } 140 141 // getUpdateDNSRecordParam is a function that returns the appropriate Record Param based on the cloudFlareChange passed in 142 func getUpdateDNSRecordParam(cfc cloudFlareChange) cloudflare.UpdateDNSRecordParams { 143 return cloudflare.UpdateDNSRecordParams{ 144 Name: cfc.ResourceRecord.Name, 145 TTL: cfc.ResourceRecord.TTL, 146 Proxied: cfc.ResourceRecord.Proxied, 147 Type: cfc.ResourceRecord.Type, 148 Content: cfc.ResourceRecord.Content, 149 } 150 } 151 152 // getCreateDNSRecordParam is a function that returns the appropriate Record Param based on the cloudFlareChange passed in 153 func getCreateDNSRecordParam(cfc cloudFlareChange) cloudflare.CreateDNSRecordParams { 154 return cloudflare.CreateDNSRecordParams{ 155 Name: cfc.ResourceRecord.Name, 156 TTL: cfc.ResourceRecord.TTL, 157 Proxied: cfc.ResourceRecord.Proxied, 158 Type: cfc.ResourceRecord.Type, 159 Content: cfc.ResourceRecord.Content, 160 } 161 } 162 163 // NewCloudFlareProvider initializes a new CloudFlare DNS based Provider. 164 func NewCloudFlareProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, proxiedByDefault bool, dryRun bool, dnsRecordsPerPage int) (*CloudFlareProvider, error) { 165 // initialize via chosen auth method and returns new API object 166 var ( 167 config *cloudflare.API 168 err error 169 ) 170 if os.Getenv("CF_API_TOKEN") != "" { 171 token := os.Getenv("CF_API_TOKEN") 172 if strings.HasPrefix(token, "file:") { 173 tokenBytes, err := os.ReadFile(strings.TrimPrefix(token, "file:")) 174 if err != nil { 175 return nil, fmt.Errorf("failed to read CF_API_TOKEN from file: %w", err) 176 } 177 token = string(tokenBytes) 178 } 179 config, err = cloudflare.NewWithAPIToken(token) 180 } else { 181 config, err = cloudflare.New(os.Getenv("CF_API_KEY"), os.Getenv("CF_API_EMAIL")) 182 } 183 if err != nil { 184 return nil, fmt.Errorf("failed to initialize cloudflare provider: %v", err) 185 } 186 provider := &CloudFlareProvider{ 187 // Client: config, 188 Client: zoneService{config}, 189 domainFilter: domainFilter, 190 zoneIDFilter: zoneIDFilter, 191 proxiedByDefault: proxiedByDefault, 192 DryRun: dryRun, 193 DNSRecordsPerPage: dnsRecordsPerPage, 194 } 195 return provider, nil 196 } 197 198 // Zones returns the list of hosted zones. 199 func (p *CloudFlareProvider) Zones(ctx context.Context) ([]cloudflare.Zone, error) { 200 result := []cloudflare.Zone{} 201 202 // if there is a zoneIDfilter configured 203 // && if the filter isn't just a blank string (used in tests) 204 if len(p.zoneIDFilter.ZoneIDs) > 0 && p.zoneIDFilter.ZoneIDs[0] != "" { 205 log.Debugln("zoneIDFilter configured. only looking up zone IDs defined") 206 for _, zoneID := range p.zoneIDFilter.ZoneIDs { 207 log.Debugf("looking up zone %s", zoneID) 208 detailResponse, err := p.Client.ZoneDetails(ctx, zoneID) 209 if err != nil { 210 log.Errorf("zone %s lookup failed, %v", zoneID, err) 211 return result, err 212 } 213 log.WithFields(log.Fields{ 214 "zoneName": detailResponse.Name, 215 "zoneID": detailResponse.ID, 216 }).Debugln("adding zone for consideration") 217 result = append(result, detailResponse) 218 } 219 return result, nil 220 } 221 222 log.Debugln("no zoneIDFilter configured, looking at all zones") 223 224 zonesResponse, err := p.Client.ListZonesContext(ctx) 225 if err != nil { 226 return nil, err 227 } 228 229 for _, zone := range zonesResponse.Result { 230 if !p.domainFilter.Match(zone.Name) { 231 log.Debugf("zone %s not in domain filter", zone.Name) 232 continue 233 } 234 result = append(result, zone) 235 } 236 237 return result, nil 238 } 239 240 // Records returns the list of records. 241 func (p *CloudFlareProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { 242 zones, err := p.Zones(ctx) 243 if err != nil { 244 return nil, err 245 } 246 247 endpoints := []*endpoint.Endpoint{} 248 for _, zone := range zones { 249 records, err := p.listDNSRecordsWithAutoPagination(ctx, zone.ID) 250 if err != nil { 251 return nil, err 252 } 253 254 // As CloudFlare does not support "sets" of targets, but instead returns 255 // a single entry for each name/type/target, we have to group by name 256 // and record to allow the planner to calculate the correct plan. See #992. 257 endpoints = append(endpoints, groupByNameAndType(records)...) 258 } 259 260 return endpoints, nil 261 } 262 263 // ApplyChanges applies a given set of changes in a given zone. 264 func (p *CloudFlareProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { 265 cloudflareChanges := []*cloudFlareChange{} 266 267 for _, endpoint := range changes.Create { 268 for _, target := range endpoint.Targets { 269 cloudflareChanges = append(cloudflareChanges, p.newCloudFlareChange(cloudFlareCreate, endpoint, target)) 270 } 271 } 272 273 for i, desired := range changes.UpdateNew { 274 current := changes.UpdateOld[i] 275 276 add, remove, leave := provider.Difference(current.Targets, desired.Targets) 277 278 for _, a := range remove { 279 cloudflareChanges = append(cloudflareChanges, p.newCloudFlareChange(cloudFlareDelete, current, a)) 280 } 281 282 for _, a := range add { 283 cloudflareChanges = append(cloudflareChanges, p.newCloudFlareChange(cloudFlareCreate, desired, a)) 284 } 285 286 for _, a := range leave { 287 cloudflareChanges = append(cloudflareChanges, p.newCloudFlareChange(cloudFlareUpdate, desired, a)) 288 } 289 } 290 291 for _, endpoint := range changes.Delete { 292 for _, target := range endpoint.Targets { 293 cloudflareChanges = append(cloudflareChanges, p.newCloudFlareChange(cloudFlareDelete, endpoint, target)) 294 } 295 } 296 297 return p.submitChanges(ctx, cloudflareChanges) 298 } 299 300 // submitChanges takes a zone and a collection of Changes and sends them as a single transaction. 301 func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloudFlareChange) error { 302 // return early if there is nothing to change 303 if len(changes) == 0 { 304 log.Info("All records are already up to date") 305 return nil 306 } 307 308 zones, err := p.Zones(ctx) 309 if err != nil { 310 return err 311 } 312 // separate into per-zone change sets to be passed to the API. 313 changesByZone := p.changesByZone(zones, changes) 314 315 var failedZones []string 316 for zoneID, changes := range changesByZone { 317 records, err := p.listDNSRecordsWithAutoPagination(ctx, zoneID) 318 if err != nil { 319 return fmt.Errorf("could not fetch records from zone, %v", err) 320 } 321 322 var failedChange bool 323 for _, change := range changes { 324 logFields := log.Fields{ 325 "record": change.ResourceRecord.Name, 326 "type": change.ResourceRecord.Type, 327 "ttl": change.ResourceRecord.TTL, 328 "action": change.Action, 329 "zone": zoneID, 330 } 331 332 log.WithFields(logFields).Info("Changing record.") 333 334 if p.DryRun { 335 continue 336 } 337 338 resourceContainer := cloudflare.ZoneIdentifier(zoneID) 339 if change.Action == cloudFlareUpdate { 340 recordID := p.getRecordID(records, change.ResourceRecord) 341 if recordID == "" { 342 log.WithFields(logFields).Errorf("failed to find previous record: %v", change.ResourceRecord) 343 continue 344 } 345 recordParam := getUpdateDNSRecordParam(*change) 346 recordParam.ID = recordID 347 err := p.Client.UpdateDNSRecord(ctx, resourceContainer, recordParam) 348 if err != nil { 349 failedChange = true 350 log.WithFields(logFields).Errorf("failed to update record: %v", err) 351 } 352 } else if change.Action == cloudFlareDelete { 353 recordID := p.getRecordID(records, change.ResourceRecord) 354 if recordID == "" { 355 log.WithFields(logFields).Errorf("failed to find previous record: %v", change.ResourceRecord) 356 continue 357 } 358 err := p.Client.DeleteDNSRecord(ctx, resourceContainer, recordID) 359 if err != nil { 360 failedChange = true 361 log.WithFields(logFields).Errorf("failed to delete record: %v", err) 362 } 363 } else if change.Action == cloudFlareCreate { 364 recordParam := getCreateDNSRecordParam(*change) 365 _, err := p.Client.CreateDNSRecord(ctx, resourceContainer, recordParam) 366 if err != nil { 367 failedChange = true 368 log.WithFields(logFields).Errorf("failed to create record: %v", err) 369 } 370 } 371 } 372 373 if failedChange { 374 failedZones = append(failedZones, zoneID) 375 } 376 } 377 378 if len(failedZones) > 0 { 379 return fmt.Errorf("failed to submit all changes for the following zones: %v", failedZones) 380 } 381 382 return nil 383 } 384 385 // AdjustEndpoints modifies the endpoints as needed by the specific provider 386 func (p *CloudFlareProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { 387 adjustedEndpoints := []*endpoint.Endpoint{} 388 for _, e := range endpoints { 389 proxied := shouldBeProxied(e, p.proxiedByDefault) 390 if proxied { 391 e.RecordTTL = 0 392 } 393 e.SetProviderSpecificProperty(source.CloudflareProxiedKey, strconv.FormatBool(proxied)) 394 395 adjustedEndpoints = append(adjustedEndpoints, e) 396 } 397 return adjustedEndpoints, nil 398 } 399 400 // changesByZone separates a multi-zone change into a single change per zone. 401 func (p *CloudFlareProvider) changesByZone(zones []cloudflare.Zone, changeSet []*cloudFlareChange) map[string][]*cloudFlareChange { 402 changes := make(map[string][]*cloudFlareChange) 403 zoneNameIDMapper := provider.ZoneIDName{} 404 405 for _, z := range zones { 406 zoneNameIDMapper.Add(z.ID, z.Name) 407 changes[z.ID] = []*cloudFlareChange{} 408 } 409 410 for _, c := range changeSet { 411 zoneID, _ := zoneNameIDMapper.FindZone(c.ResourceRecord.Name) 412 if zoneID == "" { 413 log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", c.ResourceRecord.Name) 414 continue 415 } 416 changes[zoneID] = append(changes[zoneID], c) 417 } 418 419 return changes 420 } 421 422 func (p *CloudFlareProvider) getRecordID(records []cloudflare.DNSRecord, record cloudflare.DNSRecord) string { 423 for _, zoneRecord := range records { 424 if zoneRecord.Name == record.Name && zoneRecord.Type == record.Type && zoneRecord.Content == record.Content { 425 return zoneRecord.ID 426 } 427 } 428 return "" 429 } 430 431 func (p *CloudFlareProvider) newCloudFlareChange(action string, endpoint *endpoint.Endpoint, target string) *cloudFlareChange { 432 ttl := defaultCloudFlareRecordTTL 433 proxied := shouldBeProxied(endpoint, p.proxiedByDefault) 434 435 if endpoint.RecordTTL.IsConfigured() { 436 ttl = int(endpoint.RecordTTL) 437 } 438 439 return &cloudFlareChange{ 440 Action: action, 441 ResourceRecord: cloudflare.DNSRecord{ 442 Name: endpoint.DNSName, 443 TTL: ttl, 444 Proxied: &proxied, 445 Type: endpoint.RecordType, 446 Content: target, 447 }, 448 } 449 } 450 451 // listDNSRecords performs automatic pagination of results on requests to cloudflare.ListDNSRecords with custom per_page values 452 func (p *CloudFlareProvider) listDNSRecordsWithAutoPagination(ctx context.Context, zoneID string) ([]cloudflare.DNSRecord, error) { 453 var records []cloudflare.DNSRecord 454 resultInfo := cloudflare.ResultInfo{PerPage: p.DNSRecordsPerPage, Page: 1} 455 params := cloudflare.ListDNSRecordsParams{ResultInfo: resultInfo} 456 for { 457 pageRecords, resultInfo, err := p.Client.ListDNSRecords(ctx, cloudflare.ZoneIdentifier(zoneID), params) 458 if err != nil { 459 return nil, err 460 } 461 462 records = append(records, pageRecords...) 463 params.ResultInfo = resultInfo.Next() 464 if params.ResultInfo.Done() { 465 break 466 } 467 } 468 return records, nil 469 } 470 471 func shouldBeProxied(endpoint *endpoint.Endpoint, proxiedByDefault bool) bool { 472 proxied := proxiedByDefault 473 474 for _, v := range endpoint.ProviderSpecific { 475 if v.Name == source.CloudflareProxiedKey { 476 b, err := strconv.ParseBool(v.Value) 477 if err != nil { 478 log.Errorf("Failed to parse annotation [%s]: %v", source.CloudflareProxiedKey, err) 479 } else { 480 proxied = b 481 } 482 break 483 } 484 } 485 486 if recordTypeProxyNotSupported[endpoint.RecordType] { 487 proxied = false 488 } 489 return proxied 490 } 491 492 func groupByNameAndType(records []cloudflare.DNSRecord) []*endpoint.Endpoint { 493 endpoints := []*endpoint.Endpoint{} 494 495 // group supported records by name and type 496 groups := map[string][]cloudflare.DNSRecord{} 497 498 for _, r := range records { 499 if !provider.SupportedRecordType(r.Type) { 500 continue 501 } 502 503 groupBy := r.Name + r.Type 504 if _, ok := groups[groupBy]; !ok { 505 groups[groupBy] = []cloudflare.DNSRecord{} 506 } 507 508 groups[groupBy] = append(groups[groupBy], r) 509 } 510 511 // create single endpoint with all the targets for each name/type 512 for _, records := range groups { 513 targets := make([]string, len(records)) 514 for i, record := range records { 515 targets[i] = record.Content 516 } 517 endpoints = append(endpoints, 518 endpoint.NewEndpointWithTTL( 519 records[0].Name, 520 records[0].Type, 521 endpoint.TTL(records[0].TTL), 522 targets...). 523 WithProviderSpecific(source.CloudflareProxiedKey, strconv.FormatBool(*records[0].Proxied)), 524 ) 525 } 526 527 return endpoints 528 } 529 530 // boolPtr is used as a helper function to return a pointer to a boolean 531 // Needed because some parameters require a pointer. 532 func boolPtr(b bool) *bool { 533 return &b 534 }