diff --git a/config/crds/v1/crds.kubeflare.io_zones.yaml b/config/crds/v1/crds.kubeflare.io_zones.yaml index 57debde07db00e5b0b06eff16a3076dd636be83c..442db92fbe3cf8eaa1feb5671fc70ecc4b4e169d 100644 --- a/config/crds/v1/crds.kubeflare.io_zones.yaml +++ b/config/crds/v1/crds.kubeflare.io_zones.yaml @@ -40,6 +40,8 @@ spec: type: string settings: properties: + 0rtt: + type: boolean advancedDDOS: type: boolean alwaysOnline: @@ -58,16 +60,30 @@ spec: type: string challengeTTL: type: integer + ciphers: + items: + type: string + type: array developmentMode: type: boolean emailObfuscation: type: boolean hotlinkProtection: type: boolean + http2: + type: boolean + http2Prioritization: + type: boolean + http3: + type: boolean + imageResizing: + type: boolean ipGeolocation: type: boolean ipv6: type: boolean + minTLSVersion: + type: string minify: properties: css: @@ -102,12 +118,43 @@ spec: type: boolean privacyPass: type: boolean + pseudoIPV4: + type: boolean responseBuffering: type: boolean rocketLoader: type: boolean + securityHeader: + properties: + enabled: + type: boolean + includeSubdomains: + type: boolean + maxAge: + type: integer + noSniff: + type: boolean + type: object + securityLevel: + type: string + serverSideExclude: + type: boolean + sortQueryStringForCache: + type: boolean + ssl: + type: boolean + tls13: + type: boolean + tlsClientAuth: + type: boolean + trueClientIPHeader: + type: boolean + waf: + type: boolean webp: type: boolean + websockets: + type: boolean type: object required: - apiToken diff --git a/config/crds/v1beta1/crds.kubeflare.io_zones.yaml b/config/crds/v1beta1/crds.kubeflare.io_zones.yaml index 22c00f4e1b388a6f80c4fe1cf6566abbfd545713..81f5fa3919efc71d4bda7e4cad8c5f3ecd9d7165 100644 --- a/config/crds/v1beta1/crds.kubeflare.io_zones.yaml +++ b/config/crds/v1beta1/crds.kubeflare.io_zones.yaml @@ -40,6 +40,8 @@ spec: type: string settings: properties: + 0rtt: + type: boolean advancedDDOS: type: boolean alwaysOnline: @@ -58,16 +60,30 @@ spec: type: string challengeTTL: type: integer + ciphers: + items: + type: string + type: array developmentMode: type: boolean emailObfuscation: type: boolean hotlinkProtection: type: boolean + http2: + type: boolean + http2Prioritization: + type: boolean + http3: + type: boolean + imageResizing: + type: boolean ipGeolocation: type: boolean ipv6: type: boolean + minTLSVersion: + type: string minify: properties: css: @@ -102,12 +118,43 @@ spec: type: boolean privacyPass: type: boolean + pseudoIPV4: + type: boolean responseBuffering: type: boolean rocketLoader: type: boolean + securityHeader: + properties: + enabled: + type: boolean + includeSubdomains: + type: boolean + maxAge: + type: integer + noSniff: + type: boolean + type: object + securityLevel: + type: string + serverSideExclude: + type: boolean + sortQueryStringForCache: + type: boolean + ssl: + type: boolean + tls13: + type: boolean + tlsClientAuth: + type: boolean + trueClientIPHeader: + type: boolean + waf: + type: boolean webp: type: boolean + websockets: + type: boolean type: object required: - apiToken diff --git a/integration/tests/min-tls-version/Makefile b/integration/tests/min-tls-version/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..93d7d6c426a4c081a79ee5284d9871474a68caf9 --- /dev/null +++ b/integration/tests/min-tls-version/Makefile @@ -0,0 +1,6 @@ +include ../common.mk + + +.PHONY: run +run: commonrun + diff -B ./expected.txt ./out/actual.txt \ No newline at end of file diff --git a/integration/tests/min-tls-version/after.sh b/integration/tests/min-tls-version/after.sh new file mode 100755 index 0000000000000000000000000000000000000000..41d66efd3a128abbec4673af8f29d9603b54868f --- /dev/null +++ b/integration/tests/min-tls-version/after.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +curl -X GET "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/settings/min_tls_version" \ + -H "X-Auth-Email: ${CF_API_EMAIL}" \ + -H "X-Auth-Key: ${CF_API_KEY}" \ + -H "Content-Type: application/json" \ No newline at end of file diff --git a/integration/tests/min-tls-version/before.sh b/integration/tests/min-tls-version/before.sh new file mode 100755 index 0000000000000000000000000000000000000000..8de1955765484faccbc8fe0ed04d5e9eb0605107 --- /dev/null +++ b/integration/tests/min-tls-version/before.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +curl -X PATCH "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/settings/min_tls_version" \ + -H "X-Auth-Email: ${CF_API_EMAIL}" \ + -H "X-Auth-Key: ${CF_API_KEY}" \ + -H "Content-Type: application/json" \ + --data '{"value":"1.0"}' diff --git a/integration/tests/min-tls-version/expected.txt b/integration/tests/min-tls-version/expected.txt new file mode 100644 index 0000000000000000000000000000000000000000..62166f6df85de6d11a2bb62f9ecb6ca437098346 --- /dev/null +++ b/integration/tests/min-tls-version/expected.txt @@ -0,0 +1 @@ +"1.2" diff --git a/integration/tests/min-tls-version/spec.yaml b/integration/tests/min-tls-version/spec.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b0074d06c873c1591ff11aae03290c034fcd5fb8 --- /dev/null +++ b/integration/tests/min-tls-version/spec.yaml @@ -0,0 +1,8 @@ +apiVersion: crds.kubeflare.io/v1alpha1 +kind: Zone +metadata: + name: always-use-https +spec: + apiToken: UNUSED + settings: + minTLSVersion: "1.2" \ No newline at end of file diff --git a/integration/tests/min-tls-version/verify.sh b/integration/tests/min-tls-version/verify.sh new file mode 100755 index 0000000000000000000000000000000000000000..6a4b8a42d15bd3f08c4bc45858923a192a057e93 --- /dev/null +++ b/integration/tests/min-tls-version/verify.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +cat ./out/result.json | jq '.result.value' > ./out/actual.txt \ No newline at end of file diff --git a/pkg/apis/crds/v1alpha1/zone_types.go b/pkg/apis/crds/v1alpha1/zone_types.go index fb878ec08e66a88e49d758fdc2c8bb73b9f7b4c5..7a33997953efe1149f4a0ebec959dfa74ca60bcc 100644 --- a/pkg/apis/crds/v1alpha1/zone_types.go +++ b/pkg/apis/crds/v1alpha1/zone_types.go @@ -20,6 +20,13 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +type SecurityHeader struct { + Enabled *bool `json:"enabled,omitempty"` + MaxAge *int `json:"maxAge,omitempty"` + IncludeSubdomains *bool `json:"includeSubdomains,omitempty"` + NoSniff *bool `json:"noSniff,omitempty"` +} + type MobileRedirect struct { Status *bool `json:"status,omi2tempty"` MobileSubdomain *string `json:"mobileSubdomain,omitempty"` @@ -59,6 +66,24 @@ type ZoneSettings struct { PrivacyPass *bool `json:"privacyPass,omitempty"` ResponseBuffering *bool `json:"responseBuffering,omitempty"` RocketLoader *bool `json:"rocketLoader,omitempty"` + SecurityHeader *SecurityHeader `json:"securityHeader,omitempty"` + SecurityLevel *string `json:"securityLevel,omitempty"` + ServerSideExclude *bool `json:"serverSideExclude,omitempty"` + SortQueryStringForCache *bool `json:"sortQueryStringForCache,omitempty"` + SSL *bool `json:"ssl,omitempty"` + MinTLSVersion *string `json:"minTLSVersion,omitempty"` + Ciphers []*string `json:"ciphers,omitempty"` + TLS13 *bool `json:"tls13,omitempty"` + TLSClientAuth *bool `json:"tlsClientAuth,omitempty"` + TrueClientIPHeader *bool `json:"trueClientIPHeader,omitempty"` + WAF *bool `json:"waf,omitempty"` + HTTP2 *bool `json:"http2,omitempty"` + HTTP3 *bool `json:"http3,omitempty"` + ZeroRTT *bool `json:"0rtt,omitempty"` + PseudoIPV4 *bool `json:"pseudoIPV4,omitempty"` + Websockets *bool `json:"websockets,omitempty"` + ImageResizing *bool `json:"imageResizing,omitempty"` + HTTP2Prioritization *bool `json:"http2Prioritization,omitempty"` } // ZoneSpec defines the desired state of Zone diff --git a/pkg/apis/crds/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/crds/v1alpha1/zz_generated.deepcopy.go index de3145b85348bb26dccce2a2b7e40ee4714394a7..a76f9e20d912fcbcdcc414df82150fd7caaea076 100644 --- a/pkg/apis/crds/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/crds/v1alpha1/zz_generated.deepcopy.go @@ -314,6 +314,41 @@ func (in *Record) DeepCopy() *Record { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecurityHeader) DeepCopyInto(out *SecurityHeader) { + *out = *in + if in.Enabled != nil { + in, out := &in.Enabled, &out.Enabled + *out = new(bool) + **out = **in + } + if in.MaxAge != nil { + in, out := &in.MaxAge, &out.MaxAge + *out = new(int) + **out = **in + } + if in.IncludeSubdomains != nil { + in, out := &in.IncludeSubdomains, &out.IncludeSubdomains + *out = new(bool) + **out = **in + } + if in.NoSniff != nil { + in, out := &in.NoSniff, &out.NoSniff + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecurityHeader. +func (in *SecurityHeader) DeepCopy() *SecurityHeader { + if in == nil { + return nil + } + out := new(SecurityHeader) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ValueFrom) DeepCopyInto(out *ValueFrom) { *out = *in @@ -526,6 +561,102 @@ func (in *ZoneSettings) DeepCopyInto(out *ZoneSettings) { *out = new(bool) **out = **in } + if in.SecurityHeader != nil { + in, out := &in.SecurityHeader, &out.SecurityHeader + *out = new(SecurityHeader) + (*in).DeepCopyInto(*out) + } + if in.SecurityLevel != nil { + in, out := &in.SecurityLevel, &out.SecurityLevel + *out = new(string) + **out = **in + } + if in.ServerSideExclude != nil { + in, out := &in.ServerSideExclude, &out.ServerSideExclude + *out = new(bool) + **out = **in + } + if in.SortQueryStringForCache != nil { + in, out := &in.SortQueryStringForCache, &out.SortQueryStringForCache + *out = new(bool) + **out = **in + } + if in.SSL != nil { + in, out := &in.SSL, &out.SSL + *out = new(bool) + **out = **in + } + if in.MinTLSVersion != nil { + in, out := &in.MinTLSVersion, &out.MinTLSVersion + *out = new(string) + **out = **in + } + if in.Ciphers != nil { + in, out := &in.Ciphers, &out.Ciphers + *out = make([]*string, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(string) + **out = **in + } + } + } + if in.TLS13 != nil { + in, out := &in.TLS13, &out.TLS13 + *out = new(bool) + **out = **in + } + if in.TLSClientAuth != nil { + in, out := &in.TLSClientAuth, &out.TLSClientAuth + *out = new(bool) + **out = **in + } + if in.TrueClientIPHeader != nil { + in, out := &in.TrueClientIPHeader, &out.TrueClientIPHeader + *out = new(bool) + **out = **in + } + if in.WAF != nil { + in, out := &in.WAF, &out.WAF + *out = new(bool) + **out = **in + } + if in.HTTP2 != nil { + in, out := &in.HTTP2, &out.HTTP2 + *out = new(bool) + **out = **in + } + if in.HTTP3 != nil { + in, out := &in.HTTP3, &out.HTTP3 + *out = new(bool) + **out = **in + } + if in.ZeroRTT != nil { + in, out := &in.ZeroRTT, &out.ZeroRTT + *out = new(bool) + **out = **in + } + if in.PseudoIPV4 != nil { + in, out := &in.PseudoIPV4, &out.PseudoIPV4 + *out = new(bool) + **out = **in + } + if in.Websockets != nil { + in, out := &in.Websockets, &out.Websockets + *out = new(bool) + **out = **in + } + if in.ImageResizing != nil { + in, out := &in.ImageResizing, &out.ImageResizing + *out = new(bool) + **out = **in + } + if in.HTTP2Prioritization != nil { + in, out := &in.HTTP2Prioritization, &out.HTTP2Prioritization + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneSettings. diff --git a/pkg/controller/zone/settings.go b/pkg/controller/zone/settings.go index b3cc862e1b78d72c95b5e2a3fce4f7cb6d4bcbb2..0fd283867788084c23626a831b53b6483b23be67 100644 --- a/pkg/controller/zone/settings.go +++ b/pkg/controller/zone/settings.go @@ -2,6 +2,7 @@ package zone import ( "context" + "sort" "github.com/cloudflare/cloudflare-go" "github.com/pkg/errors" @@ -162,6 +163,96 @@ func ReconcileSettings(ctx context.Context, instance crdsv1alpha1.Zone, cf *clou if needsUpdate { updatedZoneSettings = append(updatedZoneSettings, zoneSetting) } + case "security_header": + needsUpdate := compareAndUpdateSecurityHeaderZoneSetting(&zoneSetting, instance.Spec.Settings.SecurityHeader) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "security_level": + needsUpdate := compareAndUpdateStringZoneSetting(&zoneSetting, instance.Spec.Settings.SecurityLevel) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "server_side_exclude": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.ServerSideExclude) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "sort_query_string_for_cache": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.SortQueryStringForCache) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "ssl": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.SSL) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "min_tls_version": + needsUpdate := compareAndUpdateStringZoneSetting(&zoneSetting, instance.Spec.Settings.MinTLSVersion) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "ciphers": + needsUpdate := compareAndUpdateStringArrayZoneSetting(&zoneSetting, instance.Spec.Settings.Ciphers) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "tls_1_3": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.TLS13) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "tls_client_auth": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.TLSClientAuth) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "true_client_ip_header": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.TrueClientIPHeader) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "waf": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.WAF) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "http2": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.HTTP2) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "http3": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.HTTP3) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "0rtt": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.ZeroRTT) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "pseudo_ipv4": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.PseudoIPV4) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "websockets": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.Websockets) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "image_resizing": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.ImageResizing) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } + case "h2_prioritization": + needsUpdate := compareAndUpdateBoolZoneSetting(&zoneSetting, instance.Spec.Settings.HTTP2Prioritization) + if needsUpdate { + updatedZoneSettings = append(updatedZoneSettings, zoneSetting) + } } } @@ -322,3 +413,81 @@ func compareAndUpdateMobileRedirectZoneSetting(zoneSetting *cloudflare.ZoneSetti return hasChanged } + +func compareAndUpdateSecurityHeaderZoneSetting(zoneSetting *cloudflare.ZoneSetting, desiredValue *crdsv1alpha1.SecurityHeader) bool { + if desiredValue == nil { + return false + } + + hasChanged := false + + if desiredValue.Enabled != nil { + currentEnabled := zoneSetting.Value.(map[string]interface{})["enabled"].(bool) + if *desiredValue.Enabled != currentEnabled { + zoneSetting.Value.(map[string]interface{})["enabled"] = *desiredValue.Enabled + hasChanged = true + } + } + + if desiredValue.MaxAge != nil { + currentMaxAge := int(zoneSetting.Value.(map[string]interface{})["max_age"].(float64)) + if *desiredValue.MaxAge != currentMaxAge { + zoneSetting.Value.(map[string]interface{})["max_age"] = *desiredValue.MaxAge + hasChanged = true + } + } + + if desiredValue.IncludeSubdomains != nil { + currentIncludeSubdomains := zoneSetting.Value.(map[string]interface{})["include_subdomains"].(bool) + if *desiredValue.IncludeSubdomains != currentIncludeSubdomains { + zoneSetting.Value.(map[string]interface{})["include_subdomains"] = *desiredValue.IncludeSubdomains + hasChanged = true + } + } + + if desiredValue.NoSniff != nil { + currentNoSniff := zoneSetting.Value.(map[string]interface{})["no_sniff"].(bool) + if *desiredValue.NoSniff != currentNoSniff { + zoneSetting.Value.(map[string]interface{})["no_sniff"] = *desiredValue.NoSniff + hasChanged = true + } + } + + return hasChanged +} + +func compareAndUpdateStringArrayZoneSetting(zoneSetting *cloudflare.ZoneSetting, desiredValue []*string) bool { + if desiredValue == nil || len(desiredValue) == 0 { + return false + } + + current := []string{} + for _, d := range zoneSetting.Value.([]interface{}) { + current = append(current, d.(string)) + } + + desired := []string{} + for _, d := range desiredValue { + desired = append(desired, *d) + } + + sort.Strings(current) + sort.Strings(desired) + + hasChanged := len(current) != len(desired) + + if !hasChanged { + for i, v := range current { + if v != desired[i] { + hasChanged = true + } + } + } + + if !hasChanged { + return false + } + + zoneSetting.Value = desiredValue + return true +} diff --git a/pkg/controller/zone/settings_test.go b/pkg/controller/zone/settings_test.go index d8f35873d8a98d88f050acf7c8092bcbc39390d2..de4e7861917eaef3111bd150badd6a5d30479417 100644 --- a/pkg/controller/zone/settings_test.go +++ b/pkg/controller/zone/settings_test.go @@ -61,3 +61,48 @@ func Test_compareAndUpdateMobileRedirectZoneSetting(t *testing.T) { }) } } + +func Test_compareAndUpdateStringArrayZoneSetting(t *testing.T) { + tests := []struct { + name string + zoneSetting []interface{} + desiredValue []string + expected bool + }{ + { + name: "no change", + zoneSetting: []interface{}{ + "A", + }, + desiredValue: []string{ + "A", + }, + expected: false, + }, + { + name: "change", + zoneSetting: []interface{}{ + "A", + "B", + }, + desiredValue: []string{ + "A", + }, + expected: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + zoneSetting := cloudflare.ZoneSetting{ + Value: test.zoneSetting, + } + desiredValue := []*string{} + for _, d := range test.desiredValue { + desiredValue = append(desiredValue, &d) + } + + actual := compareAndUpdateStringArrayZoneSetting(&zoneSetting, desiredValue) + assert.Equal(t, test.expected, actual) + }) + } +}