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  }