diff --git a/docs/tutorials/cloudflare.md b/docs/tutorials/cloudflare.md index b2a4d40bf..71d5cc670 100644 --- a/docs/tutorials/cloudflare.md +++ b/docs/tutorials/cloudflare.md @@ -129,6 +129,7 @@ spec: - --provider=cloudflare - --cloudflare-proxied # (optional) enable the proxy feature of Cloudflare (DDOS protection, CDN...) - --cloudflare-dns-records-per-page=5000 # (optional) configure how many DNS records to fetch per request + - --cloudflare-region-key="eu" # (optional) configure which region can decrypt HTTPS requests env: - name: CF_API_KEY valueFrom: @@ -204,6 +205,7 @@ spec: - --provider=cloudflare - --cloudflare-proxied # (optional) enable the proxy feature of Cloudflare (DDOS protection, CDN...) - --cloudflare-dns-records-per-page=5000 # (optional) configure how many DNS records to fetch per request + - --cloudflare-region-key="eu" # (optional) configure which region can decrypt HTTPS requests env: - name: CF_API_KEY valueFrom: @@ -299,3 +301,9 @@ $ kubectl delete -f externaldns.yaml ## Setting cloudflare-proxied on a per-ingress basis Using the `external-dns.alpha.kubernetes.io/cloudflare-proxied: "true"` annotation on your ingress, you can specify if the proxy feature of Cloudflare should be enabled for that record. This setting will override the global `--cloudflare-proxied` setting. + +## Setting cloudflare-region-key to configure regional services + +Using the `external-dns.alpha.kubernetes.io/cloudflare-region-key` annotation on your ingress, you can restrict which data centers can decrypt and serve HTTPS traffic. A list of available options can be seen [here](https://developers.cloudflare.com/data-localization/regional-services/get-started/). + +If not set the value will default to `global`. diff --git a/main.go b/main.go index 567f5423c..dced351fd 100644 --- a/main.go +++ b/main.go @@ -244,7 +244,7 @@ func main() { case "civo": p, err = civo.NewCivoProvider(domainFilter, cfg.DryRun) case "cloudflare": - p, err = cloudflare.NewCloudFlareProvider(domainFilter, zoneIDFilter, cfg.CloudflareProxied, cfg.DryRun, cfg.CloudflareDNSRecordsPerPage) + p, err = cloudflare.NewCloudFlareProvider(domainFilter, zoneIDFilter, cfg.CloudflareProxied, cfg.DryRun, cfg.CloudflareDNSRecordsPerPage, cfg.CloudflareRegionKey) case "google": p, err = google.NewGoogleProvider(ctx, cfg.GoogleProject, domainFilter, zoneIDFilter, cfg.GoogleBatchChangeSize, cfg.GoogleBatchChangeInterval, cfg.GoogleZoneVisibility, cfg.DryRun) case "digitalocean": diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index de9ce3381..6cd78ee59 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -108,6 +108,7 @@ type Config struct { AzureZonesCacheDuration time.Duration CloudflareProxied bool CloudflareDNSRecordsPerPage int + CloudflareRegionKey string CoreDNSPrefix string AkamaiServiceConsumerDomain string AkamaiClientToken string @@ -267,6 +268,7 @@ var defaultConfig = &Config{ AzureZonesCacheDuration: 0 * time.Second, CloudflareProxied: false, CloudflareDNSRecordsPerPage: 100, + CloudflareRegionKey: "earth", CoreDNSPrefix: "/skydns/", AkamaiServiceConsumerDomain: "", AkamaiClientToken: "", @@ -492,6 +494,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("cloudflare-proxied", "When using the Cloudflare provider, specify if the proxy mode must be enabled (default: disabled)").BoolVar(&cfg.CloudflareProxied) app.Flag("cloudflare-dns-records-per-page", "When using the Cloudflare provider, specify how many DNS records listed per page, max possible 5,000 (default: 100)").Default(strconv.Itoa(defaultConfig.CloudflareDNSRecordsPerPage)).IntVar(&cfg.CloudflareDNSRecordsPerPage) + app.Flag("cloudflare-region-key", "When using the Cloudflare provider, specify the region (default: earth)").StringVar(&cfg.CloudflareRegionKey) app.Flag("coredns-prefix", "When using the CoreDNS provider, specify the prefix name").Default(defaultConfig.CoreDNSPrefix).StringVar(&cfg.CoreDNSPrefix) app.Flag("akamai-serviceconsumerdomain", "When using the Akamai provider, specify the base URL (required when --provider=akamai and edgerc-path not specified)").Default(defaultConfig.AkamaiServiceConsumerDomain).StringVar(&cfg.AkamaiServiceConsumerDomain) app.Flag("akamai-client-token", "When using the Akamai provider, specify the client token (required when --provider=akamai and edgerc-path not specified)").Default(defaultConfig.AkamaiClientToken).StringVar(&cfg.AkamaiClientToken) diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index f06e54f16..ab77cc9ec 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -75,6 +75,7 @@ var ( AzureSubscriptionID: "", CloudflareProxied: false, CloudflareDNSRecordsPerPage: 100, + CloudflareRegionKey: "", CoreDNSPrefix: "/skydns/", AkamaiServiceConsumerDomain: "", AkamaiClientToken: "", @@ -175,6 +176,7 @@ var ( AzureSubscriptionID: "arg", CloudflareProxied: true, CloudflareDNSRecordsPerPage: 5000, + CloudflareRegionKey: "us", CoreDNSPrefix: "/coredns/", AkamaiServiceConsumerDomain: "oooo-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net", AkamaiClientToken: "o184671d5307a388180fbf7f11dbdf46", @@ -277,6 +279,7 @@ func TestParseFlags(t *testing.T) { "--azure-subscription-id=arg", "--cloudflare-proxied", "--cloudflare-dns-records-per-page=5000", + "--cloudflare-region-key=us", "--coredns-prefix=/coredns/", "--akamai-serviceconsumerdomain=oooo-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net", "--akamai-client-token=o184671d5307a388180fbf7f11dbdf46", @@ -396,6 +399,7 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_AZURE_SUBSCRIPTION_ID": "arg", "EXTERNAL_DNS_CLOUDFLARE_PROXIED": "1", "EXTERNAL_DNS_CLOUDFLARE_DNS_RECORDS_PER_PAGE": "5000", + "EXTERNAL_DNS_CLOUDFLARE_REGION_KEY": "us", "EXTERNAL_DNS_COREDNS_PREFIX": "/coredns/", "EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN": "oooo-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net", "EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN": "o184671d5307a388180fbf7f11dbdf46", diff --git a/provider/cloudflare/cloudflare.go b/provider/cloudflare/cloudflare.go index 760192804..9c83a9c41 100644 --- a/provider/cloudflare/cloudflare.go +++ b/provider/cloudflare/cloudflare.go @@ -23,6 +23,7 @@ import ( "os" "strconv" "strings" + "time" cloudflare "github.com/cloudflare/cloudflare-go" log "github.com/sirupsen/logrus" @@ -73,6 +74,7 @@ type cloudFlareDNS interface { CreateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDNSRecordParams) (cloudflare.DNSRecord, error) DeleteDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, recordID string) error UpdateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDNSRecordParams) error + UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDataLocalizationRegionalHostnameParams) error } type zoneService struct { @@ -104,6 +106,11 @@ func (z zoneService) UpdateDNSRecord(ctx context.Context, rc *cloudflare.Resourc return err } +func (z zoneService) UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDataLocalizationRegionalHostnameParams) error { + _, err := z.service.UpdateDataLocalizationRegionalHostname(ctx, rc, rp) + return err +} + func (z zoneService) DeleteDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, recordID string) error { return z.service.DeleteDNSRecord(ctx, rc, recordID) } @@ -126,12 +133,14 @@ type CloudFlareProvider struct { proxiedByDefault bool DryRun bool DNSRecordsPerPage int + RegionKey string } // cloudFlareChange differentiates between ChangActions type cloudFlareChange struct { - Action string - ResourceRecord cloudflare.DNSRecord + Action string + ResourceRecord cloudflare.DNSRecord + RegionalHostname cloudflare.RegionalHostname } // RecordParamsTypes is a typeset of the possible Record Params that can be passed to cloudflare-go library @@ -150,6 +159,14 @@ func updateDNSRecordParam(cfc cloudFlareChange) cloudflare.UpdateDNSRecordParams } } +// updateDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in +func updateDataLocalizationRegionalHostnameParams(cfc cloudFlareChange) cloudflare.UpdateDataLocalizationRegionalHostnameParams { + return cloudflare.UpdateDataLocalizationRegionalHostnameParams{ + Hostname: cfc.RegionalHostname.Hostname, + RegionKey: cfc.RegionalHostname.RegionKey, + } +} + // getCreateDNSRecordParam is a function that returns the appropriate Record Param based on the cloudFlareChange passed in func getCreateDNSRecordParam(cfc cloudFlareChange) cloudflare.CreateDNSRecordParams { return cloudflare.CreateDNSRecordParams{ @@ -162,7 +179,7 @@ func getCreateDNSRecordParam(cfc cloudFlareChange) cloudflare.CreateDNSRecordPar } // NewCloudFlareProvider initializes a new CloudFlare DNS based Provider. -func NewCloudFlareProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, proxiedByDefault bool, dryRun bool, dnsRecordsPerPage int) (*CloudFlareProvider, error) { +func NewCloudFlareProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, proxiedByDefault bool, dryRun bool, dnsRecordsPerPage int, regionKey string) (*CloudFlareProvider, error) { // initialize via chosen auth method and returns new API object var ( config *cloudflare.API @@ -192,6 +209,7 @@ func NewCloudFlareProvider(domainFilter endpoint.DomainFilter, zoneIDFilter prov proxiedByDefault: proxiedByDefault, DryRun: dryRun, DNSRecordsPerPage: dnsRecordsPerPage, + RegionKey: regionKey, } return provider, nil } @@ -351,12 +369,18 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud continue } recordParam := updateDNSRecordParam(*change) + regionalHostnameParam := updateDataLocalizationRegionalHostnameParams(*change) recordParam.ID = recordID err := p.Client.UpdateDNSRecord(ctx, resourceContainer, recordParam) if err != nil { failedChange = true log.WithFields(logFields).Errorf("failed to update record: %v", err) } + regionalHostnameErr := p.Client.UpdateDataLocalizationRegionalHostname(ctx, resourceContainer, regionalHostnameParam) + if regionalHostnameErr != nil { + failedChange = true + log.WithFields(logFields).Errorf("failed to update record: %v", regionalHostnameErr) + } } else if change.Action == cloudFlareDelete { recordID := p.getRecordID(records, change.ResourceRecord) if recordID == "" { @@ -443,7 +467,7 @@ func (p *CloudFlareProvider) newCloudFlareChange(action string, endpoint *endpoi if endpoint.RecordTTL.IsConfigured() { ttl = int(endpoint.RecordTTL) } - + dt := time.Now() return &cloudFlareChange{ Action: action, ResourceRecord: cloudflare.DNSRecord{ @@ -452,6 +476,14 @@ func (p *CloudFlareProvider) newCloudFlareChange(action string, endpoint *endpoi Proxied: &proxied, Type: endpoint.RecordType, Content: target, + Meta: map[string]interface{}{ + "region": p.RegionKey, + }, + }, + RegionalHostname: cloudflare.RegionalHostname{ + Hostname: endpoint.DNSName, + RegionKey: p.RegionKey, + CreatedOn: &dt, }, } } diff --git a/provider/cloudflare/cloudflare_test.go b/provider/cloudflare/cloudflare_test.go index 564fa0461..aeb929ca0 100644 --- a/provider/cloudflare/cloudflare_test.go +++ b/provider/cloudflare/cloudflare_test.go @@ -204,6 +204,18 @@ func (m *mockCloudFlareClient) UpdateDNSRecord(ctx context.Context, rc *cloudfla return nil } +func (m *mockCloudFlareClient) UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDataLocalizationRegionalHostnameParams) error { + m.Actions = append(m.Actions, MockAction{ + Name: "UpdateDataLocalizationRegionalHostname", + ZoneId: rc.Identifier, + RecordId: "", + RecordData: cloudflare.DNSRecord{ + Name: rp.Hostname, + }, + }) + return nil +} + func (m *mockCloudFlareClient) DeleteDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, recordID string) error { m.Actions = append(m.Actions, MockAction{ Name: "Delete", @@ -706,7 +718,8 @@ func TestCloudflareProvider(t *testing.T) { provider.NewZoneIDFilter([]string{""}), false, true, - 5000) + 5000, + "") if err != nil { t.Errorf("should not fail, %s", err) } @@ -722,7 +735,8 @@ func TestCloudflareProvider(t *testing.T) { provider.NewZoneIDFilter([]string{""}), false, true, - 5000) + 5000, + "") if err != nil { t.Errorf("should not fail, %s", err) } @@ -735,7 +749,8 @@ func TestCloudflareProvider(t *testing.T) { provider.NewZoneIDFilter([]string{""}), false, true, - 5000) + 5000, + "") if err != nil { t.Errorf("should not fail, %s", err) } @@ -747,7 +762,8 @@ func TestCloudflareProvider(t *testing.T) { provider.NewZoneIDFilter([]string{""}), false, true, - 5000) + 5000, + "") if err == nil { t.Errorf("expected to fail") } @@ -1225,7 +1241,6 @@ func TestCloudflareComplexUpdate(t *testing.T) { client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{ "001": ExampleDomain, }) - provider := &CloudFlareProvider{ Client: client, } @@ -1267,7 +1282,7 @@ func TestCloudflareComplexUpdate(t *testing.T) { t.Errorf("should not fail, %s", err) } - td.CmpDeeply(t, client.Actions, []MockAction{ + mockAction := []MockAction{ { Name: "Delete", ZoneId: "001", @@ -1296,7 +1311,17 @@ func TestCloudflareComplexUpdate(t *testing.T) { Proxied: proxyEnabled, }, }, - }) + { + Name: "UpdateDataLocalizationRegionalHostname", + ZoneId: "001", + RecordData: cloudflare.DNSRecord{ + Name: "foobar.bar.com", + TTL: 0, + Proxiable: false, + }, + }, + } + td.CmpDeeply(t, client.Actions, mockAction) } func TestCustomTTLWithEnabledProxyNotChanged(t *testing.T) { @@ -1355,3 +1380,53 @@ func TestCustomTTLWithEnabledProxyNotChanged(t *testing.T) { assert.Equal(t, 0, len(planned.Changes.UpdateOld), "no new changes should be here") assert.Equal(t, 0, len(planned.Changes.Delete), "no new changes should be here") } + +func TestCloudFlareProvider_Region(t *testing.T) { + _ = os.Setenv("CF_API_TOKEN", "abc123def") + _ = os.Setenv("CF_API_EMAIL", "test@test.com") + provider, err := NewCloudFlareProvider(endpoint.NewDomainFilter([]string{"example.com"}), provider.ZoneIDFilter{}, true, false, 50, "us") + if err != nil { + t.Fatal(err) + } + + if provider.RegionKey != "us" { + t.Errorf("expected region key to be 'us', but got '%s'", provider.RegionKey) + } +} + +func TestCloudFlareProvider_updateDataLocalizationRegionalHostnameParams(t *testing.T) { + change := &cloudFlareChange{ + RegionalHostname: cloudflare.RegionalHostname{ + Hostname: "example.com", + RegionKey: "us", + }, + } + + params := updateDataLocalizationRegionalHostnameParams(*change) + if params.Hostname != "example.com" { + t.Errorf("expected hostname to be 'example.com', but got '%s'", params.Hostname) + } + + if params.RegionKey != "us" { + t.Errorf("expected region key to be 'us', but got '%s'", params.RegionKey) + } +} + +func TestCloudFlareProvider_newCloudFlareChange(t *testing.T) { + _ = os.Setenv("CF_API_KEY", "xxxxxxxxxxxxxxxxx") + _ = os.Setenv("CF_API_EMAIL", "test@test.com") + provider, err := NewCloudFlareProvider(endpoint.NewDomainFilter([]string{"example.com"}), provider.ZoneIDFilter{}, true, false, 50, "us") + if err != nil { + t.Fatal(err) + } + + endpoint := &endpoint.Endpoint{ + DNSName: "example.com", + Targets: []string{"192.0.2.1"}, + } + + change := provider.newCloudFlareChange(cloudFlareCreate, endpoint, endpoint.Targets[0]) + if change.RegionalHostname.RegionKey != "us" { + t.Errorf("expected region key to be 'us', but got '%s'", change.RegionalHostname.RegionKey) + } +}