external-dns/provider/cloudflare/cloudflare_custom_hostnames.go
Ivan Ka e1ef0bee6b
Revert "chore(deps): bump github.com/cloudflare/cloudflare-go from v5 to v6 (…" (#6338)
This reverts commit 29c35d75a711d3c0838fecaf03b330bd649bd73b.
2026-04-02 18:35:04 +05:30

320 lines
12 KiB
Go

/*
Copyright 2026 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 cloudflare
import (
"context"
"errors"
"fmt"
"maps"
"slices"
"github.com/cloudflare/cloudflare-go/v5"
"github.com/cloudflare/cloudflare-go/v5/custom_hostnames"
"github.com/cloudflare/cloudflare-go/v5/option"
log "github.com/sirupsen/logrus"
"sigs.k8s.io/external-dns/provider"
)
// customHostname represents a Cloudflare custom hostname (v5 API compatible wrapper)
type customHostname struct {
id string
hostname string
customOriginServer string
customOriginSNI string
ssl *customHostnameSSL
}
// customHostnameSSL represents SSL configuration for custom hostname
type customHostnameSSL struct {
sslType string
method string
bundleMethod string
certificateAuthority string
settings customHostnameSSLSettings
}
// customHostnameSSLSettings represents SSL settings for custom hostname
type customHostnameSSLSettings struct {
minTLSVersion string
}
// for faster getCustomHostname() lookup
type customHostnameIndex struct {
hostname string
}
type customHostnamesMap map[customHostnameIndex]customHostname
type CustomHostnamesConfig struct {
Enabled bool
MinTLSVersion string
CertificateAuthority string
}
var recordTypeCustomHostnameSupported = map[string]bool{
"A": true,
"CNAME": true,
}
func (z zoneService) CustomHostnames(ctx context.Context, zoneID string) autoPager[custom_hostnames.CustomHostnameListResponse] {
params := custom_hostnames.CustomHostnameListParams{
ZoneID: cloudflare.F(zoneID),
}
return z.service.CustomHostnames.ListAutoPaging(ctx, params)
}
func (z zoneService) DeleteCustomHostname(ctx context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error {
_, err := z.service.CustomHostnames.Delete(ctx, customHostnameID, params)
return err
}
func (z zoneService) CreateCustomHostname(ctx context.Context, zoneID string, ch customHostname) error {
params := buildCustomHostnameNewParams(zoneID, ch)
_, err := z.service.CustomHostnames.New(ctx, params,
option.WithJSONSet("custom_origin_server", ch.customOriginServer))
return err
}
// buildCustomHostnameNewParams builds the params for creating a custom hostname
func buildCustomHostnameNewParams(zoneID string, ch customHostname) custom_hostnames.CustomHostnameNewParams {
params := custom_hostnames.CustomHostnameNewParams{
ZoneID: cloudflare.F(zoneID),
Hostname: cloudflare.F(ch.hostname),
}
if ch.ssl != nil {
sslParams := custom_hostnames.CustomHostnameNewParamsSSL{}
if ch.ssl.method != "" {
sslParams.Method = cloudflare.F(custom_hostnames.DCVMethod(ch.ssl.method))
}
if ch.ssl.sslType != "" {
sslParams.Type = cloudflare.F(custom_hostnames.DomainValidationType(ch.ssl.sslType))
}
if ch.ssl.bundleMethod != "" {
sslParams.BundleMethod = cloudflare.F(custom_hostnames.BundleMethod(ch.ssl.bundleMethod))
}
if ch.ssl.certificateAuthority != "" && ch.ssl.certificateAuthority != "none" {
sslParams.CertificateAuthority = cloudflare.F(cloudflare.CertificateCA(ch.ssl.certificateAuthority))
}
if ch.ssl.settings.minTLSVersion != "" {
sslParams.Settings = cloudflare.F(custom_hostnames.CustomHostnameNewParamsSSLSettings{
MinTLSVersion: cloudflare.F(custom_hostnames.CustomHostnameNewParamsSSLSettingsMinTLSVersion(ch.ssl.settings.minTLSVersion)),
})
}
params.SSL = cloudflare.F(sslParams)
}
return params
}
// submitCustomHostnameChanges implements Custom Hostname functionality for the Change, returns false if it fails
func (p *CloudFlareProvider) submitCustomHostnameChanges(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool {
// return early if disabled
if !p.CustomHostnamesConfig.Enabled {
return true
}
switch change.Action {
case cloudFlareUpdate:
return p.processCustomHostnameUpdate(ctx, zoneID, change, chs, logFields)
case cloudFlareDelete:
return p.processCustomHostnameDelete(ctx, zoneID, change, chs, logFields)
case cloudFlareCreate:
return p.processCustomHostnameCreate(ctx, zoneID, change, chs, logFields)
}
return true
}
func (p *CloudFlareProvider) processCustomHostnameUpdate(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool {
if !recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] {
return true
}
failedChange := false
add, remove, _ := provider.Difference(change.CustomHostnamesPrev, slices.Collect(maps.Keys(change.CustomHostnames)))
for _, changeCH := range remove {
if prevCh, err := getCustomHostname(chs, changeCH); err == nil {
prevChID := prevCh.id
if prevChID != "" {
log.WithFields(logFields).Infof("Removing previous custom hostname %q/%q", prevChID, changeCH)
params := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)}
chErr := p.Client.DeleteCustomHostname(ctx, prevChID, params)
if chErr != nil {
failedChange = true
log.WithFields(logFields).Errorf("failed to remove previous custom hostname %q/%q: %v", prevChID, changeCH, chErr)
}
}
}
}
for _, changeCH := range add {
log.WithFields(logFields).Infof("Adding custom hostname %q", changeCH)
chErr := p.Client.CreateCustomHostname(ctx, zoneID, change.CustomHostnames[changeCH])
if chErr != nil {
failedChange = true
log.WithFields(logFields).Errorf("failed to add custom hostname %q: %v", changeCH, chErr)
}
}
return !failedChange
}
func (p *CloudFlareProvider) processCustomHostnameDelete(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool {
failedChange := false
for _, changeCH := range change.CustomHostnames {
if recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] && changeCH.hostname != "" {
log.WithFields(logFields).Infof("Deleting custom hostname %q", changeCH.hostname)
if ch, err := getCustomHostname(chs, changeCH.hostname); err == nil {
chID := ch.id
params := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)}
chErr := p.Client.DeleteCustomHostname(ctx, chID, params)
if chErr != nil {
failedChange = true
log.WithFields(logFields).Errorf("failed to delete custom hostname %q/%q: %v", chID, changeCH.hostname, chErr)
}
} else {
log.WithFields(logFields).Warnf("failed to delete custom hostname %q: %v", changeCH.hostname, err)
}
}
}
return !failedChange
}
func (p *CloudFlareProvider) processCustomHostnameCreate(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool {
failedChange := false
for _, changeCH := range change.CustomHostnames {
if recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] && changeCH.hostname != "" {
log.WithFields(logFields).Infof("Creating custom hostname %q", changeCH.hostname)
if ch, err := getCustomHostname(chs, changeCH.hostname); err == nil {
if changeCH.customOriginServer == ch.customOriginServer {
log.WithFields(logFields).Warnf("custom hostname %q already exists with the same origin %q, continue", changeCH.hostname, ch.customOriginServer)
} else {
failedChange = true
log.WithFields(logFields).Errorf("failed to create custom hostname, %q already exists with origin %q", changeCH.hostname, ch.customOriginServer)
}
} else {
chErr := p.Client.CreateCustomHostname(ctx, zoneID, changeCH)
if chErr != nil {
failedChange = true
log.WithFields(logFields).Errorf("failed to create custom hostname %q: %v", changeCH.hostname, chErr)
}
}
}
}
return !failedChange
}
func getCustomHostname(chs customHostnamesMap, chName string) (customHostname, error) {
if chName == "" {
return customHostname{}, fmt.Errorf("failed to get custom hostname: %q is empty", chName)
}
if ch, ok := chs[customHostnameIndex{hostname: chName}]; ok {
return ch, nil
}
return customHostname{}, fmt.Errorf("failed to get custom hostname: %q not found", chName)
}
func (p *CloudFlareProvider) newCustomHostname(hostname string, origin string) customHostname {
return customHostname{
hostname: hostname,
customOriginServer: origin,
ssl: getCustomHostnamesSSLOptions(p.CustomHostnamesConfig),
}
}
func getCustomHostnamesSSLOptions(customHostnamesConfig CustomHostnamesConfig) *customHostnameSSL {
ssl := &customHostnameSSL{
sslType: "dv",
method: "http",
bundleMethod: "ubiquitous",
settings: customHostnameSSLSettings{
minTLSVersion: customHostnamesConfig.MinTLSVersion,
},
}
// Set CertificateAuthority if provided
// We're not able to set it at all (even with a blank) if you're not on an enterprise plan
if customHostnamesConfig.CertificateAuthority != "none" {
ssl.certificateAuthority = customHostnamesConfig.CertificateAuthority
}
return ssl
}
func newCustomHostnameIndex(ch customHostname) customHostnameIndex {
return customHostnameIndex{hostname: ch.hostname}
}
// listCustomHostnamesWithPagination performs automatic pagination of results on requests to cloudflare.CustomHostnames
func (p *CloudFlareProvider) listCustomHostnamesWithPagination(ctx context.Context, zoneID string) (customHostnamesMap, error) {
if !p.CustomHostnamesConfig.Enabled {
return nil, nil
}
chs := make(customHostnamesMap)
iter := p.Client.CustomHostnames(ctx, zoneID)
customHostnames, err := listAllCustomHostnames(iter)
if err != nil {
convertedError := convertCloudflareError(err)
if !errors.Is(convertedError, provider.SoftError) {
log.Errorf("zone %q failed to fetch custom hostnames. Please check if \"Cloudflare for SaaS\" is enabled and API key permissions, %v", zoneID, err)
}
return nil, convertedError
}
for _, ch := range customHostnames {
chs[newCustomHostnameIndex(ch)] = ch
}
return chs, nil
}
// processCustomHostnameChanges applies custom hostname side-effects for each
// change in the set and returns true if any operation failed.
func (p *CloudFlareProvider) processCustomHostnameChanges(
ctx context.Context,
zoneID string,
changes []*cloudFlareChange,
chs customHostnamesMap,
) bool {
failed := false
for _, change := range changes {
logFields := log.Fields{
"record": change.ResourceRecord.Name,
"type": change.ResourceRecord.Type,
"ttl": change.ResourceRecord.TTL,
"action": change.Action.String(),
"zone": zoneID,
}
if !p.submitCustomHostnameChanges(ctx, zoneID, change, chs, logFields) {
failed = true
}
}
return failed
}
// listAllCustomHostnames extracts all custom hostnames from the iterator
func listAllCustomHostnames(iter autoPager[custom_hostnames.CustomHostnameListResponse]) ([]customHostname, error) {
var customHostnames []customHostname
for ch := range autoPagerIterator(iter) {
customHostnames = append(customHostnames, customHostname{
id: ch.ID,
hostname: ch.Hostname,
customOriginServer: ch.CustomOriginServer,
customOriginSNI: ch.CustomOriginSNI,
})
}
if iter.Err() != nil {
return nil, iter.Err()
}
return customHostnames, nil
}