/* Copyright 2017 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package provider import ( "context" "fmt" "os" "sort" "strconv" "strings" cloudflare "github.com/cloudflare/cloudflare-go" log "github.com/sirupsen/logrus" "github.com/kubernetes-incubator/external-dns/endpoint" "github.com/kubernetes-incubator/external-dns/plan" "github.com/kubernetes-incubator/external-dns/source" ) const ( // cloudFlareCreate is a ChangeAction enum value cloudFlareCreate = "CREATE" // cloudFlareDelete is a ChangeAction enum value cloudFlareDelete = "DELETE" // cloudFlareUpdate is a ChangeAction enum value cloudFlareUpdate = "UPDATE" // defaultCloudFlareRecordTTL 1 = automatic defaultCloudFlareRecordTTL = 1 ) var cloudFlareTypeNotSupported = map[string]bool{ "LOC": true, "MX": true, "NS": true, "SPF": true, "TXT": true, "SRV": true, } // cloudFlareDNS is the subset of the CloudFlare API that we actually use. Add methods as required. Signatures must match exactly. type cloudFlareDNS interface { UserDetails() (cloudflare.User, error) ZoneIDByName(zoneName string) (string, error) ListZones(zoneID ...string) ([]cloudflare.Zone, error) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) DNSRecords(zoneID string, rr cloudflare.DNSRecord) ([]cloudflare.DNSRecord, error) CreateDNSRecord(zoneID string, rr cloudflare.DNSRecord) (*cloudflare.DNSRecordResponse, error) DeleteDNSRecord(zoneID, recordID string) error UpdateDNSRecord(zoneID, recordID string, rr cloudflare.DNSRecord) error } type zoneService struct { service *cloudflare.API } func (z zoneService) UserDetails() (cloudflare.User, error) { return z.service.UserDetails() } func (z zoneService) ListZones(zoneID ...string) ([]cloudflare.Zone, error) { return z.service.ListZones(zoneID...) } func (z zoneService) ZoneIDByName(zoneName string) (string, error) { return z.service.ZoneIDByName(zoneName) } func (z zoneService) CreateDNSRecord(zoneID string, rr cloudflare.DNSRecord) (*cloudflare.DNSRecordResponse, error) { return z.service.CreateDNSRecord(zoneID, rr) } func (z zoneService) DNSRecords(zoneID string, rr cloudflare.DNSRecord) ([]cloudflare.DNSRecord, error) { return z.service.DNSRecords(zoneID, rr) } func (z zoneService) UpdateDNSRecord(zoneID, recordID string, rr cloudflare.DNSRecord) error { return z.service.UpdateDNSRecord(zoneID, recordID, rr) } func (z zoneService) DeleteDNSRecord(zoneID, recordID string) error { return z.service.DeleteDNSRecord(zoneID, recordID) } func (z zoneService) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { return z.service.ListZonesContext(ctx, opts...) } // CloudFlareProvider is an implementation of Provider for CloudFlare DNS. type CloudFlareProvider struct { Client cloudFlareDNS // only consider hosted zones managing domains ending in this suffix domainFilter DomainFilter zoneIDFilter ZoneIDFilter proxiedByDefault bool DryRun bool PaginationOptions cloudflare.PaginationOptions } // cloudFlareChange differentiates between ChangActions type cloudFlareChange struct { Action string ResourceRecordSet []cloudflare.DNSRecord } // NewCloudFlareProvider initializes a new CloudFlare DNS based Provider. func NewCloudFlareProvider(domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, zonesPerPage int, proxiedByDefault bool, dryRun bool) (*CloudFlareProvider, error) { // initialize via API email and API key and returns new API object config, err := cloudflare.New(os.Getenv("CF_API_KEY"), os.Getenv("CF_API_EMAIL")) if err != nil { return nil, fmt.Errorf("failed to initialize cloudflare provider: %v", err) } provider := &CloudFlareProvider{ //Client: config, Client: zoneService{config}, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, proxiedByDefault: proxiedByDefault, DryRun: dryRun, PaginationOptions: cloudflare.PaginationOptions{ PerPage: zonesPerPage, Page: 1, }, } return provider, nil } // Zones returns the list of hosted zones. func (p *CloudFlareProvider) Zones() ([]cloudflare.Zone, error) { result := []cloudflare.Zone{} ctx := context.TODO() p.PaginationOptions.Page = 1 for { zonesResponse, err := p.Client.ListZonesContext(ctx, cloudflare.WithPagination(p.PaginationOptions)) if err != nil { return nil, err } for _, zone := range zonesResponse.Result { if !p.domainFilter.Match(zone.Name) { continue } if !p.zoneIDFilter.Match(zone.ID) { continue } result = append(result, zone) } if p.PaginationOptions.Page == zonesResponse.ResultInfo.TotalPages { break } p.PaginationOptions.Page++ } return result, nil } // Records returns the list of records. func (p *CloudFlareProvider) Records() ([]*endpoint.Endpoint, error) { zones, err := p.Zones() if err != nil { return nil, err } endpoints := []*endpoint.Endpoint{} for _, zone := range zones { records, err := p.Client.DNSRecords(zone.ID, cloudflare.DNSRecord{}) if err != nil { return nil, err } for _, r := range records { if supportedRecordType(r.Type) { endpoints = append(endpoints, endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.TTL), r.Content).WithProviderSpecific(source.CloudflareProxiedKey, strconv.FormatBool(r.Proxied))) } } } return endpoints, nil } // ApplyChanges applies a given set of changes in a given zone. func (p *CloudFlareProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { proxiedByDefault := p.proxiedByDefault combinedChanges := make([]*cloudFlareChange, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) combinedChanges = append(combinedChanges, newCloudFlareChanges(cloudFlareCreate, changes.Create, proxiedByDefault)...) combinedChanges = append(combinedChanges, newCloudFlareChanges(cloudFlareUpdate, changes.UpdateNew, proxiedByDefault)...) combinedChanges = append(combinedChanges, newCloudFlareChanges(cloudFlareDelete, changes.Delete, proxiedByDefault)...) return p.submitChanges(combinedChanges) } // submitChanges takes a zone and a collection of Changes and sends them as a single transaction. func (p *CloudFlareProvider) submitChanges(changes []*cloudFlareChange) error { // return early if there is nothing to change if len(changes) == 0 { return nil } zones, err := p.Zones() if err != nil { return err } // separate into per-zone change sets to be passed to the API. changesByZone := p.changesByZone(zones, changes) for zoneID, changes := range changesByZone { records, err := p.Client.DNSRecords(zoneID, cloudflare.DNSRecord{}) if err != nil { return fmt.Errorf("could not fetch records from zone, %v", err) } for _, change := range changes { logFields := log.Fields{ "record": change.ResourceRecordSet[0].Name, "type": change.ResourceRecordSet[0].Type, "ttl": change.ResourceRecordSet[0].TTL, "targets": len(change.ResourceRecordSet), "action": change.Action, "zone": zoneID, } log.WithFields(logFields).Info("Changing record.") if p.DryRun { continue } recordIDs := p.getRecordIDs(records, change.ResourceRecordSet[0]) // to simplify bookkeeping for multiple records, an update is executed as delete+create if change.Action == cloudFlareDelete || change.Action == cloudFlareUpdate { for _, recordID := range recordIDs { err := p.Client.DeleteDNSRecord(zoneID, recordID) if err != nil { log.WithFields(logFields).Errorf("failed to delete record: %v", err) } } } if change.Action == cloudFlareCreate || change.Action == cloudFlareUpdate { for _, record := range change.ResourceRecordSet { _, err := p.Client.CreateDNSRecord(zoneID, record) if err != nil { log.WithFields(logFields).Errorf("failed to create record: %v", err) } } } } } return nil } // changesByZone separates a multi-zone change into a single change per zone. func (p *CloudFlareProvider) changesByZone(zones []cloudflare.Zone, changeSet []*cloudFlareChange) map[string][]*cloudFlareChange { changes := make(map[string][]*cloudFlareChange) zoneNameIDMapper := zoneIDName{} for _, z := range zones { zoneNameIDMapper.Add(z.ID, z.Name) changes[z.ID] = []*cloudFlareChange{} } for _, c := range changeSet { zoneID, _ := zoneNameIDMapper.FindZone(c.ResourceRecordSet[0].Name) if zoneID == "" { log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected ", c.ResourceRecordSet[0].Name) continue } changes[zoneID] = append(changes[zoneID], c) } return changes } func (p *CloudFlareProvider) getRecordIDs(records []cloudflare.DNSRecord, record cloudflare.DNSRecord) []string { recordIDs := make([]string, 0) for _, zoneRecord := range records { if zoneRecord.Name == record.Name && zoneRecord.Type == record.Type { recordIDs = append(recordIDs, zoneRecord.ID) } } sort.Strings(recordIDs) return recordIDs } // newCloudFlareChanges returns a collection of Changes based on the given records and action. func newCloudFlareChanges(action string, endpoints []*endpoint.Endpoint, proxiedByDefault bool) []*cloudFlareChange { changes := make([]*cloudFlareChange, 0, len(endpoints)) for _, endpoint := range endpoints { changes = append(changes, newCloudFlareChange(action, endpoint, proxiedByDefault)) } return changes } func newCloudFlareChange(action string, endpoint *endpoint.Endpoint, proxiedByDefault bool) *cloudFlareChange { ttl := defaultCloudFlareRecordTTL proxied := shouldBeProxied(endpoint, proxiedByDefault) if endpoint.RecordTTL.IsConfigured() { ttl = int(endpoint.RecordTTL) } resourceRecordSet := make([]cloudflare.DNSRecord, len(endpoint.Targets)) for i := range endpoint.Targets { resourceRecordSet[i] = cloudflare.DNSRecord{ Name: endpoint.DNSName, TTL: ttl, Proxied: proxied, Type: endpoint.RecordType, Content: endpoint.Targets[i], } } return &cloudFlareChange{ Action: action, ResourceRecordSet: resourceRecordSet, } } func shouldBeProxied(endpoint *endpoint.Endpoint, proxiedByDefault bool) bool { proxied := proxiedByDefault for _, v := range endpoint.ProviderSpecific { if v.Name == source.CloudflareProxiedKey { b, err := strconv.ParseBool(v.Value) if err != nil { log.Errorf("Failed to parse annotation [%s]: %v", source.CloudflareProxiedKey, err) } else { proxied = b } break } } if cloudFlareTypeNotSupported[endpoint.RecordType] || strings.Contains(endpoint.DNSName, "*") { proxied = false } return proxied }