Skip to content

Commit e665a73

Browse files
authored
Merge pull request #5087 from conduitxyz/mrozentsvayg/cloudflare-custom-hostname
feat(cloudflare): custom hostname and fix apex
2 parents 549ee24 + 739d34d commit e665a73

File tree

5 files changed

+581
-34
lines changed

5 files changed

+581
-34
lines changed

docs/tutorials/cloudflare.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,9 +304,15 @@ Using the `external-dns.alpha.kubernetes.io/cloudflare-proxied: "true"` annotati
304304
## Setting cloudflare-region-key to configure regional services
305305

306306
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/).
307+
Currently, requires SuperAdmin or Admin role.
307308

308309
If not set the value will default to `global`.
309310

311+
## Setting cloudflare-custom-hostname
312+
313+
Using the `external-dns.alpha.kubernetes.io/cloudflare-custom-hostname: "<custom hostname>"` annotation, you can have [custom hostnames](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/) automatically managed for A/CNAME record as a custom origin.
314+
Requires [Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/) product and "SSL and Certificates" API permission.
315+
310316
## Using CRD source to manage DNS records in Cloudflare
311317

312318
Please refer to the [CRD source documentation](../sources/crd.md#example) for more information.

provider/cloudflare/cloudflare.go

Lines changed: 176 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ var recordTypeProxyNotSupported = map[string]bool{
6464
"SRV": true,
6565
}
6666

67+
var recordTypeCustomHostnameSupported = map[string]bool{
68+
"A": true,
69+
"CNAME": true,
70+
}
71+
6772
// cloudFlareDNS is the subset of the CloudFlare API that we actually use. Add methods as required. Signatures must match exactly.
6873
type cloudFlareDNS interface {
6974
UserDetails(ctx context.Context) (cloudflare.User, error)
@@ -76,6 +81,9 @@ type cloudFlareDNS interface {
7681
DeleteDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, recordID string) error
7782
UpdateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDNSRecordParams) error
7883
UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDataLocalizationRegionalHostnameParams) error
84+
CustomHostnames(ctx context.Context, zoneID string, page int, filter cloudflare.CustomHostname) ([]cloudflare.CustomHostname, cloudflare.ResultInfo, error)
85+
DeleteCustomHostname(ctx context.Context, zoneID string, customHostnameID string) error
86+
CreateCustomHostname(ctx context.Context, zoneID string, ch cloudflare.CustomHostname) (*cloudflare.CustomHostnameResponse, error)
7987
}
8088

8189
type zoneService struct {
@@ -124,6 +132,18 @@ func (z zoneService) ZoneDetails(ctx context.Context, zoneID string) (cloudflare
124132
return z.service.ZoneDetails(ctx, zoneID)
125133
}
126134

135+
func (z zoneService) CustomHostnames(ctx context.Context, zoneID string, page int, filter cloudflare.CustomHostname) ([]cloudflare.CustomHostname, cloudflare.ResultInfo, error) {
136+
return z.service.CustomHostnames(ctx, zoneID, page, filter)
137+
}
138+
139+
func (z zoneService) DeleteCustomHostname(ctx context.Context, zoneID string, customHostnameID string) error {
140+
return z.service.DeleteCustomHostname(ctx, zoneID, customHostnameID)
141+
}
142+
143+
func (z zoneService) CreateCustomHostname(ctx context.Context, zoneID string, ch cloudflare.CustomHostname) (*cloudflare.CustomHostnameResponse, error) {
144+
return z.service.CreateCustomHostname(ctx, zoneID, ch)
145+
}
146+
127147
// CloudFlareProvider is an implementation of Provider for CloudFlare DNS.
128148
type CloudFlareProvider struct {
129149
provider.BaseProvider
@@ -142,6 +162,7 @@ type cloudFlareChange struct {
142162
Action string
143163
ResourceRecord cloudflare.DNSRecord
144164
RegionalHostname cloudflare.RegionalHostname
165+
CustomHostname cloudflare.CustomHostname
145166
}
146167

147168
// RecordParamsTypes is a typeset of the possible Record Params that can be passed to cloudflare-go library
@@ -278,10 +299,15 @@ func (p *CloudFlareProvider) Records(ctx context.Context) ([]*endpoint.Endpoint,
278299
return nil, err
279300
}
280301

302+
chs, chErr := p.listCustomHostnamesWithPagination(ctx, zone.ID)
303+
if chErr != nil {
304+
return nil, chErr
305+
}
306+
281307
// As CloudFlare does not support "sets" of targets, but instead returns
282308
// a single entry for each name/type/target, we have to group by name
283309
// and record to allow the planner to calculate the correct plan. See #992.
284-
endpoints = append(endpoints, groupByNameAndType(records)...)
310+
endpoints = append(endpoints, groupByNameAndTypeWithCustomHostnames(records, chs)...)
285311
}
286312

287313
return endpoints, nil
@@ -346,6 +372,11 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud
346372
return fmt.Errorf("could not fetch records from zone, %v", err)
347373
}
348374

375+
chs, chErr := p.listCustomHostnamesWithPagination(ctx, zoneID)
376+
if chErr != nil {
377+
return fmt.Errorf("could not fetch custom hostnames from zone, %v", chErr)
378+
}
379+
349380
var failedChange bool
350381
for _, change := range changes {
351382
logFields := log.Fields{
@@ -364,23 +395,54 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud
364395

365396
resourceContainer := cloudflare.ZoneIdentifier(zoneID)
366397
if change.Action == cloudFlareUpdate {
398+
if recordTypeCustomHostnameSupported[change.ResourceRecord.Type] {
399+
chID, oldCh := p.getCustomHostnameIDbyOrigin(chs, change.ResourceRecord.Name)
400+
if chID == "" && change.CustomHostname.Hostname != "" {
401+
log.WithFields(logFields).Infof("Adding custom hostname %v", change.CustomHostname.Hostname)
402+
_, chErr := p.Client.CreateCustomHostname(ctx, zoneID, change.CustomHostname)
403+
if chErr != nil {
404+
failedChange = true
405+
log.WithFields(logFields).Errorf("failed to add custom hostname %v: %v", change.CustomHostname.Hostname, chErr)
406+
}
407+
} else if chID != "" && oldCh != "" && change.CustomHostname.Hostname == "" {
408+
log.WithFields(logFields).Infof("Removing custom hostname %v", change.CustomHostname.Hostname)
409+
chErr := p.Client.DeleteCustomHostname(ctx, zoneID, chID)
410+
if chErr != nil {
411+
failedChange = true
412+
log.WithFields(logFields).Errorf("failed to remove custom hostname %v: %v", change.CustomHostname.Hostname, chErr)
413+
}
414+
} else if chID != "" && change.CustomHostname.Hostname != "" && oldCh != change.CustomHostname.Hostname {
415+
log.WithFields(logFields).Infof("Replacing custom hostname: %v/%v to %v", chID, oldCh, change.CustomHostname.Hostname)
416+
chDelErr := p.Client.DeleteCustomHostname(ctx, zoneID, chID)
417+
if chDelErr != nil {
418+
failedChange = true
419+
log.WithFields(logFields).Errorf("failed to remove replacing custom hostname %v/%v: %v", chID, oldCh, chDelErr)
420+
}
421+
_, chAddErr := p.Client.CreateCustomHostname(ctx, zoneID, change.CustomHostname)
422+
if chAddErr != nil {
423+
failedChange = true
424+
log.WithFields(logFields).Errorf("failed to add replacing custom hostname %v: %v", change.CustomHostname.Hostname, chAddErr)
425+
}
426+
}
427+
}
367428
recordID := p.getRecordID(records, change.ResourceRecord)
368429
if recordID == "" {
369430
log.WithFields(logFields).Errorf("failed to find previous record: %v", change.ResourceRecord)
370431
continue
371432
}
372433
recordParam := updateDNSRecordParam(*change)
373-
regionalHostnameParam := updateDataLocalizationRegionalHostnameParams(*change)
374434
recordParam.ID = recordID
375435
err := p.Client.UpdateDNSRecord(ctx, resourceContainer, recordParam)
376436
if err != nil {
377437
failedChange = true
378438
log.WithFields(logFields).Errorf("failed to update record: %v", err)
379439
}
380-
regionalHostnameErr := p.Client.UpdateDataLocalizationRegionalHostname(ctx, resourceContainer, regionalHostnameParam)
381-
if regionalHostnameErr != nil {
382-
failedChange = true
383-
log.WithFields(logFields).Errorf("failed to update record when editing region: %v", regionalHostnameErr)
440+
if regionalHostnameParam := updateDataLocalizationRegionalHostnameParams(*change); regionalHostnameParam.RegionKey != "" {
441+
regionalHostnameErr := p.Client.UpdateDataLocalizationRegionalHostname(ctx, resourceContainer, regionalHostnameParam)
442+
if regionalHostnameErr != nil {
443+
failedChange = true
444+
log.WithFields(logFields).Errorf("failed to update record when editing region: %v", regionalHostnameErr)
445+
}
384446
}
385447
} else if change.Action == cloudFlareDelete {
386448
recordID := p.getRecordID(records, change.ResourceRecord)
@@ -393,13 +455,28 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud
393455
failedChange = true
394456
log.WithFields(logFields).Errorf("failed to delete record: %v", err)
395457
}
458+
chID, oldCh := p.getCustomHostnameIDbyOrigin(chs, change.ResourceRecord.Name)
459+
if chID == "" {
460+
continue
461+
}
462+
chErr := p.Client.DeleteCustomHostname(ctx, zoneID, chID)
463+
if chErr != nil {
464+
failedChange = true
465+
log.WithFields(logFields).Errorf("failed to delete custom hostname %v/%v: %v", chID, oldCh, chErr)
466+
}
396467
} else if change.Action == cloudFlareCreate {
397468
recordParam := getCreateDNSRecordParam(*change)
398469
_, err := p.Client.CreateDNSRecord(ctx, resourceContainer, recordParam)
399470
if err != nil {
400471
failedChange = true
401472
log.WithFields(logFields).Errorf("failed to create record: %v", err)
402473
}
474+
log.WithFields(logFields).Infof("Creating custom hostname %v", change.CustomHostname.Hostname)
475+
_, chErr := p.Client.CreateCustomHostname(ctx, zoneID, change.CustomHostname)
476+
if chErr != nil {
477+
failedChange = true
478+
log.WithFields(logFields).Errorf("failed to create custom hostname %v: %v", change.CustomHostname.Hostname, chErr)
479+
}
403480
}
404481
}
405482

@@ -461,6 +538,15 @@ func (p *CloudFlareProvider) getRecordID(records []cloudflare.DNSRecord, record
461538
return ""
462539
}
463540

541+
func (p *CloudFlareProvider) getCustomHostnameIDbyOrigin(chs []cloudflare.CustomHostname, origin string) (string, string) {
542+
for _, zoneCh := range chs {
543+
if zoneCh.CustomOriginServer == origin {
544+
return zoneCh.ID, zoneCh.Hostname
545+
}
546+
}
547+
return "", ""
548+
}
549+
464550
func (p *CloudFlareProvider) newCloudFlareChange(action string, endpoint *endpoint.Endpoint, target string) *cloudFlareChange {
465551
ttl := defaultCloudFlareRecordTTL
466552
proxied := shouldBeProxied(endpoint, p.proxiedByDefault)
@@ -472,8 +558,10 @@ func (p *CloudFlareProvider) newCloudFlareChange(action string, endpoint *endpoi
472558
return &cloudFlareChange{
473559
Action: action,
474560
ResourceRecord: cloudflare.DNSRecord{
475-
Name: endpoint.DNSName,
476-
TTL: ttl,
561+
Name: endpoint.DNSName,
562+
TTL: ttl,
563+
// We have to use pointers to bools now, as the upstream cloudflare-go library requires them
564+
// see: https://github.com/cloudflare/cloudflare-go/pull/595
477565
Proxied: &proxied,
478566
Type: endpoint.RecordType,
479567
Content: target,
@@ -486,10 +574,23 @@ func (p *CloudFlareProvider) newCloudFlareChange(action string, endpoint *endpoi
486574
RegionKey: p.RegionKey,
487575
CreatedOn: &dt,
488576
},
577+
CustomHostname: cloudflare.CustomHostname{
578+
Hostname: getEndpointCustomHostname(endpoint),
579+
CustomOriginServer: endpoint.DNSName,
580+
SSL: &cloudflare.CustomHostnameSSL{
581+
Type: "dv",
582+
Method: "http",
583+
CertificateAuthority: "google",
584+
BundleMethod: "ubiquitous",
585+
Settings: cloudflare.CustomHostnameSSLSettings{
586+
MinTLSVersion: "1.0",
587+
},
588+
},
589+
},
489590
}
490591
}
491592

492-
// listDNSRecords performs automatic pagination of results on requests to cloudflare.ListDNSRecords with custom per_page values
593+
// listDNSRecordsWithAutoPagination performs automatic pagination of results on requests to cloudflare.ListDNSRecords with custom per_page values
493594
func (p *CloudFlareProvider) listDNSRecordsWithAutoPagination(ctx context.Context, zoneID string) ([]cloudflare.DNSRecord, error) {
494595
var records []cloudflare.DNSRecord
495596
resultInfo := cloudflare.ResultInfo{PerPage: p.DNSRecordsPerPage, Page: 1}
@@ -516,6 +617,33 @@ func (p *CloudFlareProvider) listDNSRecordsWithAutoPagination(ctx context.Contex
516617
return records, nil
517618
}
518619

620+
// listCustomHostnamesWithPagination performs automatic pagination of results on requests to cloudflare.CustomHostnames
621+
func (p *CloudFlareProvider) listCustomHostnamesWithPagination(ctx context.Context, zoneID string) ([]cloudflare.CustomHostname, error) {
622+
var chs []cloudflare.CustomHostname
623+
resultInfo := cloudflare.ResultInfo{Page: 1}
624+
for {
625+
pageCustomHostnameListResponse, resultInfo, err := p.Client.CustomHostnames(ctx, zoneID, resultInfo.Page, cloudflare.CustomHostname{})
626+
if err != nil {
627+
var apiErr *cloudflare.Error
628+
if errors.As(err, &apiErr) {
629+
if apiErr.ClientRateLimited() || apiErr.StatusCode >= http.StatusInternalServerError {
630+
// Handle rate limit error as a soft error
631+
return nil, provider.NewSoftError(err)
632+
}
633+
}
634+
log.Errorf("zone %s failed to fetch custom hostnames. Please check if \"Cloudflare for SaaS\" is enabled and API key permissions, %v", zoneID, err)
635+
return nil, err
636+
}
637+
638+
chs = append(chs, pageCustomHostnameListResponse...)
639+
resultInfo = resultInfo.Next()
640+
if resultInfo.Done() {
641+
break
642+
}
643+
}
644+
return chs, nil
645+
}
646+
519647
func shouldBeProxied(endpoint *endpoint.Endpoint, proxiedByDefault bool) bool {
520648
proxied := proxiedByDefault
521649

@@ -537,7 +665,16 @@ func shouldBeProxied(endpoint *endpoint.Endpoint, proxiedByDefault bool) bool {
537665
return proxied
538666
}
539667

540-
func groupByNameAndType(records []cloudflare.DNSRecord) []*endpoint.Endpoint {
668+
func getEndpointCustomHostname(endpoint *endpoint.Endpoint) string {
669+
for _, v := range endpoint.ProviderSpecific {
670+
if v.Name == source.CloudflareCustomHostnameKey {
671+
return v.Value
672+
}
673+
}
674+
return ""
675+
}
676+
677+
func groupByNameAndTypeWithCustomHostnames(records []cloudflare.DNSRecord, chs []cloudflare.CustomHostname) []*endpoint.Endpoint {
541678
endpoints := []*endpoint.Endpoint{}
542679

543680
// group supported records by name and type
@@ -556,20 +693,41 @@ func groupByNameAndType(records []cloudflare.DNSRecord) []*endpoint.Endpoint {
556693
groups[groupBy] = append(groups[groupBy], r)
557694
}
558695

696+
// map custom origin to custom hostname, custom origin should match to a dns record
697+
customOriginServers := map[string]string{}
698+
699+
// only one latest custom hostname for a dns record would work
700+
for _, c := range chs {
701+
customOriginServers[c.CustomOriginServer] = c.Hostname
702+
}
703+
559704
// create single endpoint with all the targets for each name/type
560705
for _, records := range groups {
706+
if len(records) == 0 {
707+
return endpoints
708+
}
561709
targets := make([]string, len(records))
562710
for i, record := range records {
563711
targets[i] = record.Content
564712
}
565-
endpoints = append(endpoints,
566-
endpoint.NewEndpointWithTTL(
567-
records[0].Name,
568-
records[0].Type,
569-
endpoint.TTL(records[0].TTL),
570-
targets...).
571-
WithProviderSpecific(source.CloudflareProxiedKey, strconv.FormatBool(*records[0].Proxied)),
572-
)
713+
ep := endpoint.NewEndpointWithTTL(
714+
records[0].Name,
715+
records[0].Type,
716+
endpoint.TTL(records[0].TTL),
717+
targets...)
718+
proxied := false
719+
if records[0].Proxied != nil {
720+
proxied = *records[0].Proxied
721+
}
722+
if ep == nil {
723+
continue
724+
}
725+
ep.WithProviderSpecific(source.CloudflareProxiedKey, strconv.FormatBool(proxied))
726+
if customHostname, ok := customOriginServers[records[0].Name]; ok {
727+
ep.WithProviderSpecific(source.CloudflareCustomHostnameKey, customHostname)
728+
}
729+
730+
endpoints = append(endpoints, ep)
573731
}
574732

575733
return endpoints

0 commit comments

Comments
 (0)