diff --git a/api/v1/gatewayapi_types.go b/api/v1/gatewayapi_types.go index 7e56ae8ede..857ccf3817 100644 --- a/api/v1/gatewayapi_types.go +++ b/api/v1/gatewayapi_types.go @@ -79,6 +79,51 @@ type GatewayAPISpec struct { // does not yet have any version of those CRDs. // +optional CRDManagement *CRDManagement `json:"crdManagement,omitempty"` + + // Extensions enables and configures Tigera-built add-ons that sit on top of the + // Gateway API data plane. Each add-on is opt-in: an unset Extensions, an unset + // add-on field, and an empty add-on object all leave the add-on disabled. + // +optional + Extensions *GatewayAPIExtensions `json:"extensions,omitempty"` +} + +// GatewayAPIExtensions enables and configures Tigera-built Gateway API add-ons. +type GatewayAPIExtensions struct { + // WAF enables and configures the Tigera Web Application Firewall (Coraza WASM + // + applicationlayer reconcilers). Default-off semantics: when WAF is nil, + // when WAF.State is nil, and when WAF.State is "Disabled", the operator does + // not render the WAF env vars or RBAC on calico-kube-controllers. Set + // WAF.State = "Enabled" to turn the feature on. See design + // `tigera/designs#25` (PMREQ-384) for the full surface. + // +optional + WAF *WAFExtensionSpec `json:"waf,omitempty"` +} + +// WAFExtensionSpec configures the WAF Gateway API add-on. +type WAFExtensionSpec struct { + // State turns the WAF Gateway API add-on on or off. Default (nil or + // "Disabled") means the operator does not render the WAF surface on + // calico-kube-controllers. Set to "Enabled" to opt in. + // +optional + State *WAFExtensionState `json:"state,omitempty"` +} + +// WAFExtensionState is the on/off enum for the WAF Gateway API add-on. +// +kubebuilder:validation:Enum=Enabled;Disabled +type WAFExtensionState string + +const ( + WAFExtensionStateEnabled WAFExtensionState = "Enabled" + WAFExtensionStateDisabled WAFExtensionState = "Disabled" +) + +// IsWAFGatewayExtensionEnabled returns true iff spec.extensions.waf.state == Enabled. +// Unset Extensions, unset WAF, unset State, and explicit Disabled all return false. +func (s *GatewayAPISpec) IsWAFGatewayExtensionEnabled() bool { + if s == nil || s.Extensions == nil || s.Extensions.WAF == nil || s.Extensions.WAF.State == nil { + return false + } + return *s.Extensions.WAF.State == WAFExtensionStateEnabled } type GatewayClassSpec struct { diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 7a26d77a2f..30fdfc43e1 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -4360,6 +4360,26 @@ func (in *GatewayAPI) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayAPIExtensions) DeepCopyInto(out *GatewayAPIExtensions) { + *out = *in + if in.WAF != nil { + in, out := &in.WAF, &out.WAF + *out = new(WAFExtensionSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayAPIExtensions. +func (in *GatewayAPIExtensions) DeepCopy() *GatewayAPIExtensions { + if in == nil { + return nil + } + out := new(GatewayAPIExtensions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GatewayAPIList) DeepCopyInto(out *GatewayAPIList) { *out = *in @@ -4422,6 +4442,11 @@ func (in *GatewayAPISpec) DeepCopyInto(out *GatewayAPISpec) { *out = new(CRDManagement) **out = **in } + if in.Extensions != nil { + in, out := &in.Extensions, &out.Extensions + *out = new(GatewayAPIExtensions) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayAPISpec. @@ -9806,6 +9831,26 @@ func (in *UserSearch) DeepCopy() *UserSearch { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WAFExtensionSpec) DeepCopyInto(out *WAFExtensionSpec) { + *out = *in + if in.State != nil { + in, out := &in.State, &out.State + *out = new(WAFExtensionState) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WAFExtensionSpec. +func (in *WAFExtensionSpec) DeepCopy() *WAFExtensionSpec { + if in == nil { + return nil + } + out := new(WAFExtensionSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Whisker) DeepCopyInto(out *Whisker) { *out = *in diff --git a/config/enterprise_versions.yml b/config/enterprise_versions.yml index e5761a627a..0027779df2 100644 --- a/config/enterprise_versions.yml +++ b/config/enterprise_versions.yml @@ -84,6 +84,9 @@ components: dikastes: image: dikastes version: master + coraza-wasm: + image: coraza-wasm + version: master egress-gateway: image: egress-gateway version: master diff --git a/hack/gen-versions/enterprise.go.tpl b/hack/gen-versions/enterprise.go.tpl index 7ed9089073..264b34fb5f 100644 --- a/hack/gen-versions/enterprise.go.tpl +++ b/hack/gen-versions/enterprise.go.tpl @@ -180,6 +180,15 @@ var ( variant: enterpriseVariant, } {{- end }} +{{ with index .Components "coraza-wasm" }} + ComponentCorazaWASM = Component{ + Version: "{{ .Version }}", + Image: "{{ .Image }}", + Registry: "{{ .Registry }}", + imagePath: "{{ .ImagePath }}", + variant: enterpriseVariant, + } +{{- end }} {{ with index .Components "coreos-prometheus" }} ComponentCoreOSPrometheus = Component{ Version: "{{ .Version }}", @@ -316,6 +325,7 @@ var ( ComponentGatewayL7Collector, ComponentEnvoyProxy, ComponentDikastes, + ComponentCorazaWASM, ComponentPrometheus, ComponentPrometheusAlertmanager, ComponentTigeraNode, diff --git a/pkg/common/common.go b/pkg/common/common.go index efdf171732..544ea35aff 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -36,6 +36,10 @@ const ( EgressAccessControlFeature = "egress-access-control" // PolicyRecommendation feature name PolicyRecommendationFeature = "policy-recommendation" + // GatewayAddonsFeature gates Tigera-built add-ons that layer on top of an + // ingress gateway (currently the WAF v2/v3 admission webhook). The bare + // ingress gateway data path is NOT licensed by this feature. + GatewayAddonsFeature = "ingress-gateway-addons" // MultipleOwnersLabel used to indicate multiple owner references. // If the render code places this label on an object, the object mergeState machinery will merge owner // references with any that already exist on the object rather than replace the owner references. Further diff --git a/pkg/components/enterprise.go b/pkg/components/enterprise.go index 3753ca105d..009d6581d6 100644 --- a/pkg/components/enterprise.go +++ b/pkg/components/enterprise.go @@ -162,6 +162,14 @@ var ( variant: enterpriseVariant, } + ComponentCorazaWASM = Component{ + Version: "master", + Image: "coraza-wasm", + Registry: "", + imagePath: "", + variant: enterpriseVariant, + } + ComponentCoreOSPrometheus = Component{ Version: "v3.9.1", variant: enterpriseVariant, @@ -283,6 +291,7 @@ var ( ComponentGatewayL7Collector, ComponentEnvoyProxy, ComponentDikastes, + ComponentCorazaWASM, ComponentPrometheus, ComponentPrometheusAlertmanager, ComponentTigeraNode, diff --git a/pkg/controller/applicationlayer/applicationlayer_controller.go b/pkg/controller/applicationlayer/applicationlayer_controller.go index 045581584b..abccdc6c08 100644 --- a/pkg/controller/applicationlayer/applicationlayer_controller.go +++ b/pkg/controller/applicationlayer/applicationlayer_controller.go @@ -22,6 +22,7 @@ import ( v3 "github.com/tigera/api/pkg/apis/projectcalico/v3" operatorv1 "github.com/tigera/operator/api/v1" "github.com/tigera/operator/pkg/common" + "github.com/tigera/operator/pkg/controller/gatewayapi" "github.com/tigera/operator/pkg/controller/options" "github.com/tigera/operator/pkg/controller/status" "github.com/tigera/operator/pkg/controller/utils" @@ -141,6 +142,13 @@ func add(mgr manager.Manager, c ctrlruntime.Controller) error { return fmt.Errorf("applicationlayer-controller failed to watch FelixConfiguration resource: %w", err) } + // Watch for changes to GatewayAPI; its WAF data-plane extension shares the + // FelixConfiguration WAFEventLogsFileEnabled toggle, so toggling it must re-trigger this controller. + err = c.WatchObject(&operatorv1.GatewayAPI{}, &handler.EnqueueRequestForObject{}) + if err != nil { + return fmt.Errorf("applicationlayer-controller failed to watch GatewayAPI resource: %w", err) + } + // Watch for changes to TigeraStatus. if err = utils.AddTigeraStatusWatch(c, ResourceName); err != nil { return fmt.Errorf("applicationlayer-controller failed to watch applicationlayer Tigerastatus: %w", err) @@ -177,7 +185,7 @@ func (r *ReconcileApplicationLayer) Reconcile(ctx context.Context, request recon // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. reqLogger.Info("ApplicationLayer object not found") // Patch tproxyMode if it's needed after crd deletion. - if err = r.patchFelixConfiguration(ctx, nil); err != nil { + if err = r.patchFelixConfiguration(ctx, nil, r.isGatewayWAFEnabled(ctx)); err != nil { reqLogger.Error(err, "Error patching felix configuration") } r.status.OnCRNotFound() @@ -241,7 +249,7 @@ func (r *ReconcileApplicationLayer) Reconcile(ctx context.Context, request recon } // Patch felix configuration if necessary. - if err = r.patchFelixConfiguration(ctx, instance); err != nil { + if err = r.patchFelixConfiguration(ctx, instance, r.isGatewayWAFEnabled(ctx)); err != nil { r.status.SetDegraded(operatorv1.ResourcePatchError, "Error patching felix configuration", err, reqLogger) return reconcile.Result{}, err } @@ -515,8 +523,12 @@ func (r *ReconcileApplicationLayer) getTProxyMode(al *operatorv1.ApplicationLaye // patchFelixConfiguration takes all application layer specs as arguments and patches felix config. // If at least one of the specs requires TPROXYMode as "Enabled" it'll be patched as "Enabled" otherwise it is "Disabled". -func (r *ReconcileApplicationLayer) patchFelixConfiguration(ctx context.Context, al *operatorv1.ApplicationLayer) error { +// gatewayWAFEnabled reflects the GatewayAPI WAF data-plane extension (design-25): its audit events flow through +// Felix's WAF event log, so it shares the WAFEventLogsFileEnabled toggle with the ApplicationLayer WAF. +func (r *ReconcileApplicationLayer) patchFelixConfiguration(ctx context.Context, al *operatorv1.ApplicationLayer, gatewayWAFEnabled bool) error { _, err := utils.PatchFelixConfiguration(ctx, r.client, func(fc *v3.FelixConfiguration) (bool, error) { + wafEventLogsFileEnabled := wafEventLogsFileRequired(al, gatewayWAFEnabled) + var tproxyMode string if ok, v := r.getTProxyMode(al); ok { tproxyMode = v @@ -529,6 +541,14 @@ func (r *ReconcileApplicationLayer) patchFelixConfiguration(ctx context.Context, // // The felix bug was fixed in v3.16, v3.15.1 and v3.14.4; it should be safe to set new config fields // once we know we're only upgrading from those versions and above. + // + // WAFEventLogsFileEnabled is an independent field: still enable it when a WAF producer + // (ApplicationLayer or the gateway data plane) requires it, without touching TPROXYMode. + if wafEventLogsFileEnabled && (fc.Spec.WAFEventLogsFileEnabled == nil || !*fc.Spec.WAFEventLogsFileEnabled) { + fc.Spec.WAFEventLogsFileEnabled = &wafEventLogsFileEnabled + log.Info("Patching FelixConfiguration: ", "wafEventLogsFileEnabled", wafEventLogsFileEnabled) + return true, nil + } return false, nil } @@ -541,8 +561,6 @@ func (r *ReconcileApplicationLayer) patchFelixConfiguration(ctx context.Context, policySyncPrefix := r.getPolicySyncPathPrefix(&fc.Spec, al) policySyncPrefixSetDesired := fc.Spec.PolicySyncPathPrefix == policySyncPrefix tproxyModeSetDesired := fc.Spec.TPROXYMode != "" && fc.Spec.TPROXYMode == string(tproxyMode) - wafEventLogsFileEnabled := al != nil && ((al.Spec.SidecarInjection != nil && *al.Spec.SidecarInjection == operatorv1.SidecarEnabled) || - (al.Spec.WebApplicationFirewall != nil && *al.Spec.WebApplicationFirewall == operatorv1.WAFEnabled)) wafEventLogsFileEnabledDesired := fc.Spec.WAFEventLogsFileEnabled != nil && *fc.Spec.WAFEventLogsFileEnabled == wafEventLogsFileEnabled // If tproxy mode is already set to desired state return false to indicate patch not needed. @@ -565,3 +583,21 @@ func (r *ReconcileApplicationLayer) patchFelixConfiguration(ctx context.Context, return err } + +// wafEventLogsFileRequired reports whether Felix should write WAF event logs to file, which is required +// when either the ApplicationLayer WAF/sidecar or the GatewayAPI WAF data-plane extension is enabled. +func wafEventLogsFileRequired(al *operatorv1.ApplicationLayer, gatewayWAFEnabled bool) bool { + return gatewayWAFEnabled || + (al != nil && ((al.Spec.SidecarInjection != nil && *al.Spec.SidecarInjection == operatorv1.SidecarEnabled) || + (al.Spec.WebApplicationFirewall != nil && *al.Spec.WebApplicationFirewall == operatorv1.WAFEnabled))) +} + +// isGatewayWAFEnabled reports whether the GatewayAPI WAF data-plane extension is enabled. A missing or +// unreadable GatewayAPI CR is treated as disabled. +func (r *ReconcileApplicationLayer) isGatewayWAFEnabled(ctx context.Context) bool { + gw, _, err := gatewayapi.GetGatewayAPI(ctx, r.client) + if err != nil { + return false + } + return gw.Spec.IsWAFGatewayExtensionEnabled() +} diff --git a/pkg/controller/applicationlayer/applicationlayer_controller_test.go b/pkg/controller/applicationlayer/applicationlayer_controller_test.go index c6f8f51ede..091204256e 100644 --- a/pkg/controller/applicationlayer/applicationlayer_controller_test.go +++ b/pkg/controller/applicationlayer/applicationlayer_controller_test.go @@ -227,6 +227,34 @@ var _ = Describe("Application layer controller tests", func() { Expect(fc.Spec.TPROXYMode).To(Equal("")) }) + It("should enable WAFEventLogsFileEnabled when the GatewayAPI WAF extension is enabled (no ApplicationLayer CR)", func() { + // The gateway data-plane WAF (design-25) emits audit events that flow through Felix's WAF event + // log, so it requires the same FelixConfiguration toggle as the legacy ApplicationLayer WAF — even + // when no ApplicationLayer CR is present. + mockStatus.On("OnCRNotFound").Return() + + By("creating a GatewayAPI CR with the WAF extension enabled") + wafEnabled := operatorv1.WAFExtensionStateEnabled + Expect(c.Create(ctx, &operatorv1.GatewayAPI{ + ObjectMeta: metav1.ObjectMeta{Name: "default"}, + Spec: operatorv1.GatewayAPISpec{ + Extensions: &operatorv1.GatewayAPIExtensions{ + WAF: &operatorv1.WAFExtensionSpec{State: &wafEnabled}, + }, + }, + })).NotTo(HaveOccurred()) + + By("reconciling without an ApplicationLayer resource") + _, err := r.Reconcile(ctx, reconcile.Request{}) + Expect(err).ShouldNot(HaveOccurred()) + + By("ensuring felix WAFEventLogsFileEnabled is true") + fc := v3.FelixConfiguration{ObjectMeta: metav1.ObjectMeta{Name: "default"}} + Expect(test.GetResource(c, &fc)).To(BeNil()) + Expect(fc.Spec.WAFEventLogsFileEnabled).NotTo(BeNil()) + Expect(*fc.Spec.WAFEventLogsFileEnabled).To(BeTrue()) + }) + It("should render accurate resources for for log collection", func() { mockStatus.On("AddDaemonsets", mock.Anything).Return() mockStatus.On("AddDeployments", mock.Anything).Return() diff --git a/pkg/controller/installation/core_controller.go b/pkg/controller/installation/core_controller.go index a018c16364..a8c46da5b8 100644 --- a/pkg/controller/installation/core_controller.go +++ b/pkg/controller/installation/core_controller.go @@ -78,6 +78,7 @@ import ( "github.com/tigera/operator/pkg/imports/admission" "github.com/tigera/operator/pkg/imports/crds" "github.com/tigera/operator/pkg/render" + "github.com/tigera/operator/pkg/render/applicationlayer" rcertificatemanagement "github.com/tigera/operator/pkg/render/certificatemanagement" relasticsearch "github.com/tigera/operator/pkg/render/common/elasticsearch" "github.com/tigera/operator/pkg/render/common/networkpolicy" @@ -213,6 +214,13 @@ func Add(mgr manager.Manager, opts options.ControllerOptions) error { } // Watch for changes to KubeControllersConfiguration. + // Watch GatewayAPI: spec.extensions.waf.state gates the WAF v3 surface on + // calico-kube-controllers. See design tigera/designs#25 (PMREQ-384) §Gating. + if err := c.WatchObject(&operatorv1.GatewayAPI{}, &handler.EnqueueRequestForObject{}); err != nil { + log.V(5).Info("Failed to create GatewayAPI watch", "err", err) + return fmt.Errorf("core-controller failed to watch operator GatewayAPI resource: %w", err) + } + err = c.WatchObject(&v3.KubeControllersConfiguration{}, &handler.EnqueueRequestForObject{}) if err != nil { return fmt.Errorf("tigera-installation-controller failed to watch KubeControllersConfiguration resource: %w", err) @@ -1352,18 +1360,56 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile } + // Read the GatewayAPI CR (if present) to decide whether to render the WAF + // v3 (Gateway API add-on) surface — env vars, RBAC, applicationlayer + // reconciler, and the in-process admission webhook — on + // calico-kube-controllers. Default-off: if no GatewayAPI CR exists or + // spec.extensions.waf.state != Enabled, the WAF surface is not rendered. + // See design tigera/designs#25 (PMREQ-384) §Gating. + wafGatewayExtensionEnabled := false + gatewayAPI := &operatorv1.GatewayAPI{} + if err := r.client.Get(ctx, utils.DefaultInstanceKey, gatewayAPI); err == nil { + wafGatewayExtensionEnabled = gatewayAPI.Spec.IsWAFGatewayExtensionEnabled() + } else if !apierrors.IsNotFound(err) { + r.status.SetDegraded(operatorv1.ResourceReadError, "Error reading GatewayAPI", err, reqLogger) + return reconcile.Result{}, err + } + + // When the WAF v3 surface is enabled, issue the serving cert for the + // in-process WAF admission webhook (hosted by calico-kube-controllers, + // fronted by the tigera-waf-webhook Service). It is materialized into + // calico-system alongside the other kube-controllers certs below and mounted + // into the Pod by the kube-controllers render. + var wafWebhookTLS certificatemanagement.KeyPairInterface + if wafGatewayExtensionEnabled { + wafWebhookTLS, err = certificateManager.GetOrCreateKeyPair( + r.client, + applicationlayer.WAFWebhookServerTLSSecretName, + common.OperatorNamespace(), + dns.GetServiceDNSNames(applicationlayer.WAFWebhookServiceName, common.CalicoNamespace, r.clusterDomain)) + if err != nil { + r.status.SetDegraded(operatorv1.ResourceCreateError, "Error creating WAF admission webhook TLS certificate", err, reqLogger) + return reconcile.Result{}, err + } + } + + keyPairOptions := []rcertificatemanagement.KeyPairOption{ + rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.NodeSecret, true, true), + rcertificatemanagement.NewKeyPairOption(nodePrometheusTLS, true, true), + rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.TyphaSecret, true, true), + rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.TyphaSecretNonClusterHost, true, true), + rcertificatemanagement.NewKeyPairOption(kubeControllerTLS, true, true), + } + if wafWebhookTLS != nil { + keyPairOptions = append(keyPairOptions, rcertificatemanagement.NewKeyPairOption(wafWebhookTLS, true, true)) + } + components = append(components, rcertificatemanagement.CertificateManagement(&rcertificatemanagement.Config{ Namespace: common.CalicoNamespace, ServiceAccounts: []string{render.CalicoNodeObjectName, render.TyphaServiceAccountName, kubecontrollers.KubeControllerServiceAccount}, - KeyPairOptions: []rcertificatemanagement.KeyPairOption{ - rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.NodeSecret, true, true), - rcertificatemanagement.NewKeyPairOption(nodePrometheusTLS, true, true), - rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.TyphaSecret, true, true), - rcertificatemanagement.NewKeyPairOption(typhaNodeTLS.TyphaSecretNonClusterHost, true, true), - rcertificatemanagement.NewKeyPairOption(kubeControllerTLS, true, true), - }, - TrustedBundle: typhaNodeTLS.TrustedBundle, + KeyPairOptions: keyPairOptions, + TrustedBundle: typhaNodeTLS.TrustedBundle, })) // Check if non-cluster host feature is enabled. @@ -1610,9 +1656,19 @@ func (r *ReconcileInstallation) Reconcile(ctx context.Context, request reconcile TrustedBundle: typhaNodeTLS.TrustedBundle, Namespace: common.CalicoNamespace, BindingNamespaces: []string{common.CalicoNamespace}, + WAFGatewayExtensionEnabled: wafGatewayExtensionEnabled, + WAFWebhookServerTLS: wafWebhookTLS, } components = append(components, kubecontrollers.NewCalicoKubeControllers(&kubeControllersCfg)) + // Render the in-process WAF admission webhook Service + ValidatingWebhookConfiguration. + // The webhook is served by calico-kube-controllers; the caBundle is the + // operator CA that issued the serving cert above. + if wafGatewayExtensionEnabled { + components = append(components, render.NewPassthrough( + applicationlayer.WAFAdmissionWebhookComponents(certificateManager.KeyPair().GetCertificatePEM()), nil)) + } + // v3 NetworkPolicy will fail to reconcile if the API server deployment is unhealthy. In case the API Server // deployment becomes unhealthy and reconciliation of non-NetworkPolicy resources in the core controller // would resolve it, we render the network policies of components last to prevent a chicken-and-egg scenario. diff --git a/pkg/imports/crds/operator/operator.tigera.io_gatewayapis.yaml b/pkg/imports/crds/operator/operator.tigera.io_gatewayapis.yaml index ef44739d46..9416586be6 100644 --- a/pkg/imports/crds/operator/operator.tigera.io_gatewayapis.yaml +++ b/pkg/imports/crds/operator/operator.tigera.io_gatewayapis.yaml @@ -83,6 +83,31 @@ spec: - name - namespace type: object + extensions: + description: |- + Extensions enables and configures Tigera-built add-ons that sit on top of the + Gateway API data plane. Each add-on is opt-in: an unset Extensions, an unset + add-on field, and an empty add-on object all leave the add-on disabled. + properties: + waf: + description: |- + WAF enables and configures the Tigera Web Application Firewall (Coraza WASM + when WAF.State is nil, and when WAF.State is "Disabled", the operator does + not render the WAF env vars or RBAC on calico-kube-controllers. Set + WAF.State = "Enabled" to turn the feature on. See design + `tigera/designs#25` (PMREQ-384) for the full surface. + properties: + state: + description: |- + State turns the WAF Gateway API add-on on or off. Default (nil or + "Disabled") means the operator does not render the WAF surface on + calico-kube-controllers. Set to "Enabled" to opt in. + enum: + - Enabled + - Disabled + type: string + type: object + type: object gatewayCertgenJob: description: Allows customization of the gateway certgen job. properties: diff --git a/pkg/render/applicationlayer/gateway_waf.go b/pkg/render/applicationlayer/gateway_waf.go new file mode 100644 index 0000000000..41dcac85c6 --- /dev/null +++ b/pkg/render/applicationlayer/gateway_waf.go @@ -0,0 +1,155 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// 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 applicationlayer + +import ( + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/tigera/operator/pkg/common" +) + +const ( + // WAFWebhookServerTLSSecretName is the serving-cert Secret for the in-process + // WAF admission webhook, issued for the WAFWebhookServiceName DNS name and + // mounted into calico-kube-controllers. + WAFWebhookServerTLSSecretName = "calico-kube-controllers-waf-webhook-tls" + + // WAFWebhookServiceName fronts the WAF SecLang validating admission webhook. + // The webhook is served in-process by the calico-kube-controllers Pod (see + // tigera/calico-private kube-controllers applicationlayer manager), so this + // Service selects the kube-controllers Pod rather than a dedicated + // Deployment. The webhook serving certificate is issued for this Service's + // DNS name and mounted into kube-controllers (see pkg/render/kubecontrollers). + WAFWebhookServiceName = "tigera-waf-webhook" + + // wafWebhookContainerPort is the in-process webhook server port on the + // calico-kube-controllers Pod (controller-runtime webhook server). Must match + // the port the kube-controllers applicationlayer manager listens on. + wafWebhookContainerPort = int32(9443) + + // wafWebhookPath is the admission path the kube-controllers webhook server + // registers. Must match WAFWebhookPath in the calico-private applicationlayer + // manager. + wafWebhookPath = "/validate-waf" + + // wafWebhookConfigName / wafWebhookName name the ValidatingWebhookConfiguration + // and its single webhook entry. + wafWebhookConfigName = "tigera-waf.applicationlayer.projectcalico.org" + wafWebhookName = "waf.applicationlayer.projectcalico.org" +) + +// WAFAdmissionWebhookComponents returns the objects required to expose the WAF +// SecLang validating admission webhook: a Service fronting the +// calico-kube-controllers Pod and the ValidatingWebhookConfiguration that points +// at it. The webhook itself runs in-process inside calico-kube-controllers — no +// separate Deployment, ServiceAccount, or ClusterRole; it reuses the +// kube-controllers ServiceAccount and ClusterRole (RBAC is rendered in +// pkg/render/kubecontrollers). The caller passes caBundle — the PEM of the CA +// that issued the webhook serving cert (the operator CA), so the apiserver can +// verify the in-process webhook endpoint. +// +// The caller is responsible for invoking this only when the gateway-addons +// license feature is present and the GatewayAPI WAF extension is enabled. +func WAFAdmissionWebhookComponents(caBundle []byte) []client.Object { + return []client.Object{ + wafWebhookService(), + wafValidatingWebhookConfiguration(caBundle), + } +} + +// wafWebhookService fronts the in-process webhook on the calico-kube-controllers +// Pod. The selector matches the kube-controllers Pod label (k8s-app), and the +// service port (443) forwards to the in-process webhook container port. +func wafWebhookService() *corev1.Service { + return &corev1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: WAFWebhookServiceName, + Namespace: common.CalicoNamespace, + Labels: map[string]string{"k8s-app": common.KubeControllersDeploymentName}, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"k8s-app": common.KubeControllersDeploymentName}, + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: 443, + Protocol: corev1.ProtocolTCP, + TargetPort: intstr.FromInt32(wafWebhookContainerPort), + }, + }, + Type: corev1.ServiceTypeClusterIP, + }, + } +} + +// wafValidatingWebhookConfiguration rejects unsafe AO-supplied SecLang at +// admission. It intercepts CREATE/UPDATE on WAFPlugin and WAFPolicy (the +// resources that carry AO SecLang) and fails closed: FailurePolicy=Fail so an +// unavailable webhook blocks the (infrequent) WAF resource writes rather than +// admitting unvalidated directives. The in-cluster reconciler backstop is +// status-only, so the webhook is the hard admission gate. +func wafValidatingWebhookConfiguration(caBundle []byte) *admissionregistrationv1.ValidatingWebhookConfiguration { + failPolicy := admissionregistrationv1.Fail + sideEffects := admissionregistrationv1.SideEffectClassNone + timeoutSeconds := int32(10) + + return &admissionregistrationv1.ValidatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{ + Kind: "ValidatingWebhookConfiguration", + APIVersion: "admissionregistration.k8s.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: wafWebhookConfigName, + Labels: map[string]string{"k8s-app": common.KubeControllersDeploymentName}, + }, + Webhooks: []admissionregistrationv1.ValidatingWebhook{ + { + Name: wafWebhookName, + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.Create, + admissionregistrationv1.Update, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + APIVersions: []string{"v3"}, + Resources: []string{"wafplugins", "wafpolicies"}, + Scope: ptr.To(admissionregistrationv1.NamespacedScope), + }, + }, + }, + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: common.CalicoNamespace, + Name: WAFWebhookServiceName, + Path: ptr.To(wafWebhookPath), + }, + CABundle: caBundle, + }, + AdmissionReviewVersions: []string{"v1"}, + SideEffects: &sideEffects, + TimeoutSeconds: &timeoutSeconds, + FailurePolicy: &failPolicy, + }, + }, + } +} diff --git a/pkg/render/applicationlayer/gateway_waf_test.go b/pkg/render/applicationlayer/gateway_waf_test.go new file mode 100644 index 0000000000..eea74ddf40 --- /dev/null +++ b/pkg/render/applicationlayer/gateway_waf_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. + +// 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 applicationlayer_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + + "github.com/tigera/operator/pkg/render/applicationlayer" +) + +var fakeCABundle = []byte("fake-ca-bundle") + +// The webhook runs in-process in calico-kube-controllers, so the render emits +// only a Service fronting the kube-controllers Pod plus the +// ValidatingWebhookConfiguration — no Deployment/ServiceAccount/ClusterRole. +func TestWAFAdmissionWebhookComponents_HasExpectedKinds(t *testing.T) { + objs := applicationlayer.WAFAdmissionWebhookComponents(fakeCABundle) + got := map[string]int{} + for _, o := range objs { + got[o.GetObjectKind().GroupVersionKind().Kind]++ + } + require.Len(t, objs, 2, "expected exactly 2 objects (Service + ValidatingWebhookConfiguration)") + require.Equal(t, 1, got["Service"], "expected 1 Service") + require.Equal(t, 1, got["ValidatingWebhookConfiguration"], "expected 1 ValidatingWebhookConfiguration") + require.Zero(t, got["Deployment"], "in-process webhook must not render a Deployment") + require.Zero(t, got["ServiceAccount"], "in-process webhook reuses the kube-controllers ServiceAccount") + require.Zero(t, got["ClusterRole"], "in-process webhook reuses the kube-controllers ClusterRole") +} + +// The Service must front the calico-kube-controllers Pod and forward to the +// in-process webhook port (9443). +func TestWAFAdmissionWebhookComponents_ServiceFrontsKubeControllers(t *testing.T) { + objs := applicationlayer.WAFAdmissionWebhookComponents(fakeCABundle) + var svc *corev1.Service + for _, o := range objs { + if s, ok := o.(*corev1.Service); ok { + svc = s + } + } + require.NotNil(t, svc, "expected a Service") + require.Equal(t, "calico-kube-controllers", svc.Spec.Selector["k8s-app"], "Service must select the kube-controllers Pod") + require.Len(t, svc.Spec.Ports, 1) + require.Equal(t, int32(443), svc.Spec.Ports[0].Port) + require.Equal(t, int32(9443), svc.Spec.Ports[0].TargetPort.IntVal, "must forward to the in-process webhook port") +} + +// The webhook must intercept WAFPlugin/WAFPolicy on the /validate-waf path, +// carry the supplied CA bundle, and fail closed. +func TestWAFAdmissionWebhookComponents_WebhookContract(t *testing.T) { + objs := applicationlayer.WAFAdmissionWebhookComponents(fakeCABundle) + var vwc *admissionregistrationv1.ValidatingWebhookConfiguration + for _, o := range objs { + if w, ok := o.(*admissionregistrationv1.ValidatingWebhookConfiguration); ok { + vwc = w + } + } + require.NotNil(t, vwc, "expected a ValidatingWebhookConfiguration") + require.Len(t, vwc.Webhooks, 1) + wh := vwc.Webhooks[0] + + require.Len(t, wh.Rules, 1) + require.ElementsMatch(t, []string{"wafplugins", "wafpolicies"}, wh.Rules[0].Resources) + require.Equal(t, []admissionregistrationv1.OperationType{ + admissionregistrationv1.Create, admissionregistrationv1.Update, + }, wh.Rules[0].Operations) + + require.NotNil(t, wh.ClientConfig.Service) + require.Equal(t, "tigera-waf-webhook", wh.ClientConfig.Service.Name) + require.Equal(t, "/validate-waf", *wh.ClientConfig.Service.Path) + require.Equal(t, fakeCABundle, wh.ClientConfig.CABundle, "caBundle must be the supplied issuing-CA PEM") + + require.NotNil(t, wh.FailurePolicy) + require.Equal(t, admissionregistrationv1.Fail, *wh.FailurePolicy, "webhook must fail closed") +} diff --git a/pkg/render/fluentd.go b/pkg/render/fluentd.go index 9673bcc1e6..ac651f2db0 100644 --- a/pkg/render/fluentd.go +++ b/pkg/render/fluentd.go @@ -694,6 +694,9 @@ func (c *fluentdComponent) envvars() []corev1.EnvVar { {Name: "FLUENT_UID", Value: "0"}, {Name: "FLOW_LOG_FILE", Value: c.path("/var/log/calico/flowlogs/flows.log")}, {Name: "DNS_LOG_FILE", Value: c.path("/var/log/calico/dnslogs/dns.log")}, + // WAF events are written by Felix (gated by FelixConfiguration.WAFEventLogsFileEnabled) and tailed by the + // fluentd-node WAF source. The path is always set; the file only exists when a WAF producer is enabled. + {Name: "WAF_LOG_FILE", Value: c.path("/var/log/calico/waf/waf.log")}, {Name: "FLUENTD_ES_SECURE", Value: "true"}, {Name: "NODENAME", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "spec.nodeName"}}}, {Name: "LINSEED_TOKEN", Value: c.path(GetLinseedTokenPath(c.cfg.ManagedCluster))}, diff --git a/pkg/render/fluentd_test.go b/pkg/render/fluentd_test.go index a4627156fd..66fc1c13ec 100644 --- a/pkg/render/fluentd_test.go +++ b/pkg/render/fluentd_test.go @@ -139,6 +139,7 @@ var _ = Describe("Tigera Secure Fluentd rendering tests", func() { corev1.EnvVar{Name: "FLUENT_UID", Value: "0"}, corev1.EnvVar{Name: "FLOW_LOG_FILE", Value: "/var/log/calico/flowlogs/flows.log"}, corev1.EnvVar{Name: "DNS_LOG_FILE", Value: "/var/log/calico/dnslogs/dns.log"}, + corev1.EnvVar{Name: "WAF_LOG_FILE", Value: "/var/log/calico/waf/waf.log"}, corev1.EnvVar{Name: "FLUENTD_ES_SECURE", Value: "true"}, corev1.EnvVar{ Name: "NODENAME", @@ -326,6 +327,7 @@ var _ = Describe("Tigera Secure Fluentd rendering tests", func() { corev1.EnvVar{Name: "FLUENT_UID", Value: "0"}, corev1.EnvVar{Name: "FLOW_LOG_FILE", Value: "/var/log/calico/flowlogs/flows.log"}, corev1.EnvVar{Name: "DNS_LOG_FILE", Value: "/var/log/calico/dnslogs/dns.log"}, + corev1.EnvVar{Name: "WAF_LOG_FILE", Value: "/var/log/calico/waf/waf.log"}, corev1.EnvVar{Name: "FLUENTD_ES_SECURE", Value: "true"}, corev1.EnvVar{ Name: "NODENAME", @@ -435,6 +437,7 @@ var _ = Describe("Tigera Secure Fluentd rendering tests", func() { corev1.EnvVar{Name: "FLUENT_UID", Value: "0"}, corev1.EnvVar{Name: "FLOW_LOG_FILE", Value: "/var/log/calico/flowlogs/flows.log"}, corev1.EnvVar{Name: "DNS_LOG_FILE", Value: "/var/log/calico/dnslogs/dns.log"}, + corev1.EnvVar{Name: "WAF_LOG_FILE", Value: "/var/log/calico/waf/waf.log"}, corev1.EnvVar{Name: "FLUENTD_ES_SECURE", Value: "true"}, corev1.EnvVar{ Name: "NODENAME", @@ -554,6 +557,7 @@ var _ = Describe("Tigera Secure Fluentd rendering tests", func() { {Name: "FLUENT_UID", Value: "0"}, {Name: "FLOW_LOG_FILE", Value: "c:/var/log/calico/flowlogs/flows.log"}, {Name: "DNS_LOG_FILE", Value: "c:/var/log/calico/dnslogs/dns.log"}, + {Name: "WAF_LOG_FILE", Value: "c:/var/log/calico/waf/waf.log"}, {Name: "FLUENTD_ES_SECURE", Value: "true"}, { Name: "NODENAME", @@ -574,6 +578,7 @@ var _ = Describe("Tigera Secure Fluentd rendering tests", func() { {Name: "FLUENT_UID", Value: "0"}, {Name: "FLOW_LOG_FILE", Value: "c:/var/log/calico/flowlogs/flows.log"}, {Name: "DNS_LOG_FILE", Value: "c:/var/log/calico/dnslogs/dns.log"}, + {Name: "WAF_LOG_FILE", Value: "c:/var/log/calico/waf/waf.log"}, {Name: "FLUENTD_ES_SECURE", Value: "true"}, { Name: "NODENAME", diff --git a/pkg/render/gatewayapi/gateway_api.go b/pkg/render/gatewayapi/gateway_api.go index fa58f0d4fb..0a7790fc1a 100644 --- a/pkg/render/gatewayapi/gateway_api.go +++ b/pkg/render/gatewayapi/gateway_api.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" "io" + "slices" "strings" "sync" @@ -108,6 +109,22 @@ const ( EnvoyGatewayDeploymentContainerName = "envoy-gateway" EnvoyGatewayJobContainerName = "envoy-gateway-certgen" wafFilterName = "waf-http-filter" + + // wafLogComponentWasm is the Envoy "wasm" logger component. Envoy Gateway does not + // define a const for it (its enum omits wasm), but EnvoyProxy.Spec.Logging.Level + // passes arbitrary component keys through to Envoy's --component-log-level arg, and + // Envoy recognises "wasm". Setting it to info surfaces the Coraza WASM filter's + // "AuditLog:" lines (emitted via proxywasm.LogInfo) in Envoy's application log. + wafLogComponentWasm = envoyapi.ProxyLogComponent("wasm") + + // wafAuditLogPath is the file that Envoy's application log is redirected to via + // --log-path, and that the l7-log-collector tails for Coraza "AuditLog:" lines + // (WAF_AUDIT_LOG_PATH). It lives on the "access-logs" emptyDir that is already + // mounted in both the envoy container (which writes it) and the l7-log-collector + // (which reads it) - so no extra volume or mount is needed. Envoy will not create + // parent directories for --log-path, so this is a file directly under the existing + // /access_logs mount, not a new subdirectory. + wafAuditLogPath = "/access_logs/envoy.log" ) var ( @@ -952,6 +969,30 @@ func (pr *gatewayAPIImplementationComponent) envoyProxyConfig(className, ns stri // The WAF HTTP filter is not supported when the envoy proxy is deployed as a DaemonSet // as there is no support for init containers in a DaemonSet. if envoyProxy.Spec.Provider.Kubernetes.EnvoyDeployment != nil { + // Tune Envoy log levels for WAF audit capture: the wasm component logs at + // info so the Coraza filter's "AuditLog:" lines reach Envoy's application + // log, while the default stays at warn to keep the redirected log file + // approximately just the audit lines. A user-supplied default level (e.g. + // for debugging) is preserved. + if envoyProxy.Spec.Logging.Level == nil { + envoyProxy.Spec.Logging.Level = map[envoyapi.ProxyLogComponent]envoyapi.LogLevel{} + } + if _, ok := envoyProxy.Spec.Logging.Level[envoyapi.LogComponentDefault]; !ok { + envoyProxy.Spec.Logging.Level[envoyapi.LogComponentDefault] = envoyapi.LogLevelWarn + } + envoyProxy.Spec.Logging.Level[wafLogComponentWasm] = envoyapi.LogLevelInfo + + // Redirect Envoy's application log (where the wasm filter's "AuditLog:" lines + // land) to a file on the "access-logs" emptyDir so the l7-log-collector can + // tail it (the collector already mounts that volume). EnvoyProxy has no native + // log-path field, and a Patch on the envoy container's args would replace Envoy + // Gateway's generated args, so use ExtraArgs, which EG appends to the proxy + // command line. func-e parses each element as a single token, so the flag and + // value are separate elements. A user-supplied --log-path is left untouched. + if !slices.Contains(envoyProxy.Spec.ExtraArgs, "--log-path") { + envoyProxy.Spec.ExtraArgs = append(envoyProxy.Spec.ExtraArgs, "--log-path", wafAuditLogPath) + } + // Add or update the Init Container to the deployment wafHTTPFilter := corev1.Container{ Name: wafFilterName, @@ -999,6 +1040,12 @@ func (pr *gatewayAPIImplementationComponent) envoyProxyConfig(className, ns stri Name: "ENVOY_ACCESS_LOG_PATH", Value: "/access_logs/access.log", }, + // WAF audit capture: file the collector tails for the wasm filter's + // Coraza "AuditLog:" lines (Envoy's app log, redirected via --log-path). + { + Name: "WAF_AUDIT_LOG_PATH", + Value: wafAuditLogPath, + }, // Owning Gateway info from pod labels (set by EnvoyProxy) OwningGatewayNameEnvVar, OwningGatewayNamespaceEnvVar, @@ -1434,12 +1481,22 @@ func gatewayAPIControllerPolicy(namespace string, openShift bool) *v3.NetworkPol Selector: EnvoyGatewayPolicySelector, Types: []v3.PolicyType{v3.PolicyTypeIngress, v3.PolicyTypeEgress}, // 9443: webhook. 18000-18002: xDS. 19001: metrics. + // A single Calico rule's Nets must be one address family, so the + // IPv4 and IPv6 allow-from-anywhere CIDRs are split into separate + // rules (dual-stack and IPv6-only clusters both need ::/0). Ingress: []v3.Rule{ { Action: v3.Allow, Protocol: &networkpolicy.TCPProtocol, - // Dual-stack and IPv6-only need ::/0 in addition to 0.0.0.0/0. - Source: v3.EntityRule{Nets: []string{"0.0.0.0/0", "::/0"}}, + Source: v3.EntityRule{Nets: []string{"0.0.0.0/0"}}, + Destination: v3.EntityRule{ + Ports: networkpolicy.Ports(9443, 18000, 18001, 18002, 19001), + }, + }, + { + Action: v3.Allow, + Protocol: &networkpolicy.TCPProtocol, + Source: v3.EntityRule{Nets: []string{"::/0"}}, Destination: v3.EntityRule{ Ports: networkpolicy.Ports(9443, 18000, 18001, 18002, 19001), }, diff --git a/pkg/render/gatewayapi/gateway_api_test.go b/pkg/render/gatewayapi/gateway_api_test.go index fb8f4cfcdd..661ece740e 100644 --- a/pkg/render/gatewayapi/gateway_api_test.go +++ b/pkg/render/gatewayapi/gateway_api_test.go @@ -1116,6 +1116,12 @@ value: MountPath: "/var/run/felix", }, })) + // WAF audit capture: the l7-log-collector tails the redirected Envoy app log on + // the access-logs volume it already mounts. + Expect(envoyDeployment.InitContainers[1].Env).To(ContainElement(corev1.EnvVar{ + Name: "WAF_AUDIT_LOG_PATH", + Value: "/access_logs/envoy.log", + })) // logger gateway name and namespace are set from the k8s downward api pod metadata. Expect(envoyDeployment.InitContainers[0].Env).To(ContainElements(GatewayNameEnvVar, GatewayNamespaceEnvVar)) @@ -1132,6 +1138,18 @@ value: })) Expect(proxy.Spec.Telemetry.AccessLog.Settings).To(Equal(AccessLogSettings)) + + // WAF audit capture: the wasm component logs at info so Coraza "AuditLog:" lines + // reach Envoy's application log, while everything else stays at warn so the + // redirected log file is approximately just the audit lines. + Expect(proxy.Spec.Logging.Level).To(HaveKeyWithValue(envoyapi.LogComponentDefault, envoyapi.LogLevelWarn)) + Expect(proxy.Spec.Logging.Level).To(HaveKeyWithValue(envoyapi.ProxyLogComponent("wasm"), envoyapi.LogLevelInfo)) + + // WAF audit capture: Envoy's application log is redirected to a file on the + // var-log-calico HostPath volume via --log-path (appended through ExtraArgs, + // which Envoy Gateway adds to the proxy args verbatim - each token a separate + // element). The l7-log-collector tails this file. + Expect(proxy.Spec.ExtraArgs).To(Equal([]string{"--log-path", "/access_logs/envoy.log"})) }) It("should deploy waf-http-filter for Enterprise when using a custom proxy", func() { @@ -1251,6 +1269,10 @@ value: MountPath: "/var/run/felix", }, })) + Expect(envoyDeployment.InitContainers[2].Env).To(ContainElement(corev1.EnvVar{ + Name: "WAF_AUDIT_LOG_PATH", + Value: "/access_logs/envoy.log", + })) Expect(envoyDeployment.Container).ToNot(BeNil()) Expect(envoyDeployment.Container.VolumeMounts).To(ContainElements( @@ -1734,6 +1756,12 @@ value: Expect(err).NotTo(HaveOccurred()) Expect(policy.Spec.Tier).To(Equal("calico-system")) Expect(policy.Spec.Selector).To(Equal(EnvoyGatewayPolicySelector)) + // A single Calico rule's Nets must be one address family — the ingress + // allow is split per family, not {"0.0.0.0/0","::/0"} in one rule, which + // Calico rejects ("rule contains both IPv4 and IPv6 CIDRs"). + Expect(policy.Spec.Ingress).To(HaveLen(2)) + Expect(policy.Spec.Ingress[0].Source.Nets).To(Equal([]string{"0.0.0.0/0"})) + Expect(policy.Spec.Ingress[1].Source.Nets).To(Equal([]string{"::/0"})) _, err = rtest.GetResourceOfType[*v3.NetworkPolicy](objsToCreate, "calico-system.default-deny", common.CalicoNamespace) Expect(err).To(HaveOccurred(), "must not render default-deny in calico-system") }) diff --git a/pkg/render/kubecontrollers/kube-controllers.go b/pkg/render/kubecontrollers/kube-controllers.go index 7db1c163fd..c2f535adcd 100644 --- a/pkg/render/kubecontrollers/kube-controllers.go +++ b/pkg/render/kubecontrollers/kube-controllers.go @@ -16,6 +16,7 @@ package kubecontrollers import ( "fmt" + "path/filepath" "slices" "strconv" "strings" @@ -55,6 +56,12 @@ const ( KubeControllerMetrics = "calico-kube-controllers-metrics" KubeControllerNetworkPolicyName = networkpolicy.CalicoComponentPolicyPrefix + "kube-controller-access" + // wafWebhookContainerPort is the in-process WAF admission-webhook server + // port on calico-kube-controllers. Must match the TargetPort of the + // tigera-waf-webhook Service (see pkg/render/applicationlayer) and the port + // the calico-private applicationlayer manager's webhook server listens on. + wafWebhookContainerPort = int32(9443) + EsKubeController = "es-calico-kube-controllers" EsKubeControllerRole = "es-calico-kube-controllers" EsKubeControllerRoleBinding = "es-calico-kube-controllers" @@ -108,6 +115,23 @@ type KubeControllersConfiguration struct { // Tenant object provides tenant configuration for both single and multi-tenant modes. // If this is nil, then we should run in zero-tenant mode. Tenant *operatorv1.Tenant + + // WAFGatewayExtensionEnabled gates the WAF v3 (Gateway API add-on) surface + // on calico-kube-controllers: the applicationlayer controller enablement, + // the WAF / Gateway-API / EnvoyExtensionPolicy / event / secret-replication + // RBAC, the WASM_IMAGE / WASM_PULL_SECRET / WASM_CA_CERT env vars, and the + // coraza-wasm image resolution. Sourced from + // `GatewayAPI.spec.extensions.waf.state == Enabled` (default off). + // See design `tigera/designs#25` (PMREQ-384). + WAFGatewayExtensionEnabled bool + + // WAFWebhookServerTLS is the serving certificate for the in-process WAF + // SecLang validating admission webhook hosted by calico-kube-controllers. + // When set (WAF enabled), it is mounted into the Pod and the webhook server + // reads it from WAF_WEBHOOK_CERT_DIR. Issued for the tigera-waf-webhook + // Service DNS name. Nil leaves the Deployment untouched (and the in-process + // server self-disables when the cert is absent). + WAFWebhookServerTLS certificatemanagement.KeyPairInterface } func NewCalicoKubeControllersPolicy(cfg *KubeControllersConfiguration, defaultDeny *v3.NetworkPolicy) render.Component { @@ -155,6 +179,9 @@ func NewCalicoKubeControllers(cfg *KubeControllersConfiguration) *kubeController }, ) enabledControllers = append(enabledControllers, "service", "federatedservices", "usage") + if cfg.WAFGatewayExtensionEnabled { + enabledControllers = append(enabledControllers, "applicationlayer") + } } return &kubeControllersComponent{ @@ -234,6 +261,12 @@ type kubeControllersComponent struct { kubeControllerCalicoSystemPolicy *v3.NetworkPolicy enabledControllers []string + + // wasmImage is the fully-resolved OCI reference for the Coraza WAF wasm + // binary (Enterprise only). Surfaced to the kube-controllers binary via + // the WASM_IMAGE env var; consumed by the applicationlayer reconcilers + // in tigera/calico-private to program WAF policy attachments. + wasmImage string } func (c *kubeControllersComponent) ResolveImages(is *operatorv1.ImageSet) error { @@ -242,7 +275,16 @@ func (c *kubeControllersComponent) ResolveImages(is *operatorv1.ImageSet) error prefix := c.cfg.Installation.ImagePrefix var err error c.calicoImage, err = components.GetReference(components.CombinedCalicoImage(c.cfg.Installation), reg, path, prefix, is) - return err + if err != nil { + return err + } + if c.cfg.Installation.Variant.IsEnterprise() && c.cfg.WAFGatewayExtensionEnabled { + c.wasmImage, err = components.GetReference(components.ComponentCorazaWASM, reg, path, prefix, is) + if err != nil { + return err + } + } + return nil } func (c *kubeControllersComponent) SupportedOSType() rmeta.OSType { @@ -476,6 +518,92 @@ func kubeControllersRoleEnterpriseCommonRules(cfg *KubeControllersConfiguration) }, } + if cfg.WAFGatewayExtensionEnabled { + // WAF v3 (Gateway API add-on) RBAC. Gated by + // GatewayAPI.spec.extensions.waf.state == Enabled. + rules = append(rules, + // Application-layer (gateway-addons) reconcilers reconcile WAF resources + // against Gateway API targetRefs and emit events on the policy objects. + rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies", "globalwafpolicies", + "wafplugins", "globalwafplugins", + "wafvalidationpolicies", "globalwafvalidationpolicies", + }, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/status", "globalwafpolicies/status", + "wafplugins/status", "globalwafplugins/status", + "wafvalidationpolicies/status", "globalwafvalidationpolicies/status", + }, + Verbs: []string{"get", "update", "patch"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/finalizers", "globalwafpolicies/finalizers", + "wafplugins/finalizers", "globalwafplugins/finalizers", + "wafvalidationpolicies/finalizers", "globalwafvalidationpolicies/finalizers", + }, + Verbs: []string{"update"}, + }, + rbacv1.PolicyRule{ + // Validate Gateway API targetRefs and surface attachment status. + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways", "httproutes", "tcproutes", "tlsroutes", "grpcroutes"}, + Verbs: []string{"get", "list", "watch", "update", "patch"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways/status", "httproutes/status", "tcproutes/status", "tlsroutes/status", "grpcroutes/status"}, + Verbs: []string{"get", "update", "patch"}, + }, + // controller-runtime Reconcilers (e.g. the applicationlayer manager) record + // events on watched objects via Recorder.Eventf; both core and events.k8s.io + // API groups are emitted depending on the kubernetes version. + rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + }, + rbacv1.PolicyRule{ + APIGroups: []string{"events.k8s.io"}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + }, + // Application-layer reconciler replicates the WAF wasm pull Secret from + // the controller namespace (calico-system) into each WAFPolicy's + // namespace so the rendered EnvoyExtensionPolicy can reference it. Also + // replicates CA-cert ConfigMaps when WASM_CA_CERT is set. + rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets", "configmaps"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + // Application-layer reconciler emits one EnvoyExtensionPolicy per WAF + // targetRef to bind the Coraza wasm filter at the gateway / route. + rbacv1.PolicyRule{ + APIGroups: []string{"gateway.envoyproxy.io"}, + Resources: []string{"envoyextensionpolicies"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + }, + // Application-layer reconciler stamps each namespace with its + // allocated WAF rule-id range (applicationlayer.projectcalico.org/waf-id-range + // annotation) so application operators can author in-range rules. The + // base role already grants namespaces get/list/watch; the annotation + // write needs patch/update, gated to the WAF path. + rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "patch", "update"}, + }, + ) + } + if cfg.ManagementClusterConnection != nil { rules = append(rules, rbacv1.PolicyRule{ @@ -571,6 +699,39 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { if c.cfg.Installation.CalicoNetwork != nil && c.cfg.Installation.CalicoNetwork.MultiInterfaceMode != nil { env = append(env, corev1.EnvVar{Name: "MULTI_INTERFACE_MODE", Value: c.cfg.Installation.CalicoNetwork.MultiInterfaceMode.Value()}) } + + // Application-layer (gateway-addons / WAF v3) env vars, gated by + // GatewayAPI.spec.extensions.waf.state == Enabled. When the gate is + // off (default), none of the WASM_* env vars are rendered and the + // kube-controllers binary skips the WAF reconcilers entirely (see the + // applicationlayer entry in enabledControllers). + if c.cfg.WAFGatewayExtensionEnabled { + // Application-layer (gateway-addons) reconcilers consume the Coraza WAF + // wasm OCI reference from this env var to program WAF policy attachments. + // Empty when ResolveImages was not called for the Calico variant; the + // reconciler stamps Programmed=False/WASMUnavailable in that case. + if c.wasmImage != "" { + env = append(env, corev1.EnvVar{Name: "WASM_IMAGE", Value: c.wasmImage}) + } + + // WASM_PULL_SECRET names the imagePullSecret the reconciler replicates + // from the kube-controllers namespace into a WAFPolicy's namespace so + // the rendered EnvoyExtensionPolicy can pull the wasm OCI artifact from + // a private Tigera registry. Source the name from the first + // Installation.ImagePullSecrets entry so multi-tenant / BYO-registry + // installs reuse whatever pull secret operator already attaches here. + if len(c.cfg.Installation.ImagePullSecrets) > 0 { + env = append(env, corev1.EnvVar{Name: "WASM_PULL_SECRET", Value: c.cfg.Installation.ImagePullSecrets[0].Name}) + } + + // WASM_CA_CERT names the trusted CA bundle ConfigMap (already mounted + // on this Deployment via TrustedBundle) that the reconciler replicates + // alongside WASM_PULL_SECRET so the EnvoyExtensionPolicy wasm fetcher + // trusts the registry's TLS chain. + if c.cfg.TrustedBundle != nil { + env = append(env, corev1.EnvVar{Name: "WASM_CA_CERT", Value: certificatemanagement.TrustedCertConfigMapName}) + } + } } if c.cfg.MetricsServerTLS != nil { @@ -585,6 +746,15 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { corev1.EnvVar{Name: "CA_CRT_PATH", Value: c.cfg.TrustedBundle.MountPath()}, ) } + if c.cfg.WAFWebhookServerTLS != nil { + // The in-process WAF admission webhook server (calico-private + // applicationlayer manager) reads its serving cert (tls.crt/tls.key) + // from this directory; the controller-runtime webhook server only + // registers when the cert is present. + env = append(env, + corev1.EnvVar{Name: "WAF_WEBHOOK_CERT_DIR", Value: filepath.Dir(c.cfg.WAFWebhookServerTLS.VolumeMountCertificateFilePath())}, + ) + } // UID 999 is used in kube-controller Dockerfile. sc := securitycontext.NewNonRootContext() @@ -628,6 +798,16 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { VolumeMounts: c.kubeControllersVolumeMounts(), } + if c.cfg.WAFWebhookServerTLS != nil { + // Expose the in-process WAF admission-webhook port that the + // tigera-waf-webhook Service forwards to. + container.Ports = append(container.Ports, corev1.ContainerPort{ + Name: "waf-webhook", + ContainerPort: wafWebhookContainerPort, + Protocol: corev1.ProtocolTCP, + }) + } + if c.kubeControllerName == EsKubeController && !c.cfg.Tenant.MultiTenant() { _, esHost, esPort, _ := url.ParseEndpoint(relasticsearch.GatewayEndpoint(c.SupportedOSType(), c.cfg.ClusterDomain, render.ElasticsearchNamespace)) container.Env = append(container.Env, []corev1.EnvVar{ @@ -643,6 +823,9 @@ func (c *kubeControllersComponent) controllersDeployment() *appsv1.Deployment { if c.cfg.MetricsServerTLS != nil && c.cfg.MetricsServerTLS.UseCertificateManagement() { initContainers = append(initContainers, c.cfg.MetricsServerTLS.InitContainer(c.cfg.Namespace, sc)) } + if c.cfg.WAFWebhookServerTLS != nil && c.cfg.WAFWebhookServerTLS.UseCertificateManagement() { + initContainers = append(initContainers, c.cfg.WAFWebhookServerTLS.InitContainer(c.cfg.Namespace, sc)) + } tolerations := appendUniqueTolerations(c.cfg.Installation.ControlPlaneTolerations, rmeta.TolerateCriticalAddonsAndControlPlane...) if c.cfg.Installation.KubernetesProvider.IsGKE() { tolerations = appendUniqueTolerations(tolerations, rmeta.TolerateGKEARM64NoSchedule) @@ -793,6 +976,9 @@ func (c *kubeControllersComponent) kubeControllersVolumeMounts() []corev1.Volume if c.cfg.MetricsServerTLS != nil { mounts = append(mounts, c.cfg.MetricsServerTLS.VolumeMount(c.SupportedOSType())) } + if c.cfg.WAFWebhookServerTLS != nil { + mounts = append(mounts, c.cfg.WAFWebhookServerTLS.VolumeMount(c.SupportedOSType())) + } return mounts } @@ -804,6 +990,9 @@ func (c *kubeControllersComponent) kubeControllersVolumes() []corev1.Volume { if c.cfg.MetricsServerTLS != nil { volumes = append(volumes, c.cfg.MetricsServerTLS.Volume()) } + if c.cfg.WAFWebhookServerTLS != nil { + volumes = append(volumes, c.cfg.WAFWebhookServerTLS.Volume()) + } return volumes } diff --git a/pkg/render/kubecontrollers/kube-controllers_test.go b/pkg/render/kubecontrollers/kube-controllers_test.go index 83b119aaa0..2b06e480be 100644 --- a/pkg/render/kubecontrollers/kube-controllers_test.go +++ b/pkg/render/kubecontrollers/kube-controllers_test.go @@ -16,6 +16,7 @@ package kubecontrollers_test import ( "fmt" + "path/filepath" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -40,6 +41,7 @@ import ( ctrlrfake "github.com/tigera/operator/pkg/ctrlruntime/client/fake" "github.com/tigera/operator/pkg/dns" "github.com/tigera/operator/pkg/render" + "github.com/tigera/operator/pkg/render/applicationlayer" rmeta "github.com/tigera/operator/pkg/render/common/meta" "github.com/tigera/operator/pkg/render/common/networkpolicy" rtest "github.com/tigera/operator/pkg/render/common/test" @@ -244,7 +246,14 @@ var _ = Describe("kube-controllers rendering tests", func() { } instance.Variant = operatorv1.CalicoEnterprise + // Pull secret on the Installation propagates through the Deployment's + // imagePullSecrets and is also surfaced via WASM_PULL_SECRET so the + // applicationlayer reconciler can reference it from rendered + // EnvoyExtensionPolicies in WAFPolicy namespaces. + instance.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "tigera-pull-secret"}} cfg.MetricsPort = 9094 + // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. + cfg.WAFGatewayExtensionEnabled = true component := kubecontrollers.NewCalicoKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -262,16 +271,95 @@ var _ = Describe("kube-controllers rendering tests", func() { dp := rtest.GetResource(resources, kubecontrollers.KubeController, common.CalicoNamespace, "apps", "v1", "Deployment").(*appsv1.Deployment) Expect(dp.Spec.Template.Spec.Containers[0].Image).To(Equal("test-reg/tigera/calico:" + components.ComponentTigeraCalico.Version)) + Expect(dp.Spec.Template.Spec.ImagePullSecrets).To(ContainElement(corev1.LocalObjectReference{Name: "tigera-pull-secret"})) envs := dp.Spec.Template.Spec.Containers[0].Env Expect(envs).To(ContainElement(corev1.EnvVar{ - Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage", + Name: "ENABLED_CONTROLLERS", Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer", + })) + // Application-layer reconcilers consume these env vars to program WAF + // EnvoyExtensionPolicy attachments. + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "WASM_IMAGE", Value: "test-reg/tigera/coraza-wasm:" + components.ComponentCorazaWASM.Version, + })) + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "WASM_PULL_SECRET", Value: "tigera-pull-secret", + })) + // TrustedBundle is set on the configuration above, so WASM_CA_CERT + // names the standard tigera trusted-bundle ConfigMap. + Expect(envs).To(ContainElement(corev1.EnvVar{ + Name: "WASM_CA_CERT", Value: certificatemanagement.TrustedCertConfigMapName, })) Expect(len(dp.Spec.Template.Spec.Containers[0].VolumeMounts)).To(Equal(1)) Expect(len(dp.Spec.Template.Spec.Volumes)).To(Equal(1)) clusterRole := rtest.GetResource(resources, kubecontrollers.KubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(28), "cluster role should have 28 rules") + Expect(clusterRole.Rules).To(HaveLen(38), "cluster role should have 38 rules") + + // Application-layer reconciler RBAC: WAF CRDs (resources, /status, /finalizers). + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies", "globalwafpolicies", + "wafplugins", "globalwafplugins", + "wafvalidationpolicies", "globalwafvalidationpolicies", + }, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + })) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/status", "globalwafpolicies/status", + "wafplugins/status", "globalwafplugins/status", + "wafvalidationpolicies/status", "globalwafvalidationpolicies/status", + }, + Verbs: []string{"get", "update", "patch"}, + })) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"applicationlayer.projectcalico.org"}, + Resources: []string{ + "wafpolicies/finalizers", "globalwafpolicies/finalizers", + "wafplugins/finalizers", "globalwafplugins/finalizers", + "wafvalidationpolicies/finalizers", "globalwafvalidationpolicies/finalizers", + }, + Verbs: []string{"update"}, + })) + // Gateway API targetRef validation + status patching. + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways", "httproutes", "tcproutes", "tlsroutes", "grpcroutes"}, + Verbs: []string{"get", "list", "watch", "update", "patch"}, + })) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways/status", "httproutes/status", "tcproutes/status", "tlsroutes/status", "grpcroutes/status"}, + Verbs: []string{"get", "update", "patch"}, + })) + // Recorder.Eventf emits to both core/events and events.k8s.io/events. + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + })) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"events.k8s.io"}, + Resources: []string{"events"}, + Verbs: []string{"create", "patch"}, + })) + // Cluster-wide secrets+configmaps CRUD: reconciler replicates pull + // secrets and CA bundles from the controller namespace into target + // WAFPolicy namespaces. + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets", "configmaps"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + })) + // EnvoyExtensionPolicy CRUD: reconciler renders one EEP per WAF targetRef. + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{"gateway.envoyproxy.io"}, + Resources: []string{"envoyextensionpolicies"}, + Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, + })) ms := rtest.GetResource(resources, kubecontrollers.KubeControllerMetrics, common.CalicoNamespace, "", "v1", "Service").(*corev1.Service) Expect(ms.Spec.ClusterIP).To(Equal("None"), "metrics service should be headless") @@ -326,6 +414,8 @@ var _ = Describe("kube-controllers rendering tests", func() { cfg.LogStorageExists = true cfg.KubeControllersGatewaySecret = &testutils.KubeControllersUserSecret cfg.MetricsPort = 9094 + // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. + cfg.WAFGatewayExtensionEnabled = true component := kubecontrollers.NewElasticsearchKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -358,7 +448,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(dp.Spec.Template.Spec.Volumes[0].ConfigMap.Name).To(Equal("tigera-ca-bundle")) clusterRole := rtest.GetResource(resources, kubecontrollers.EsKubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(26), "cluster role should have 26 rules") + Expect(clusterRole.Rules).To(HaveLen(36), "cluster role should have 36 rules") Expect(clusterRole.Rules).To(ContainElement( rbacv1.PolicyRule{ APIGroups: []string{""}, @@ -393,6 +483,8 @@ var _ = Describe("kube-controllers rendering tests", func() { instance.Variant = operatorv1.CalicoEnterprise cfg.ManagementCluster = &operatorv1.ManagementCluster{} cfg.MetricsPort = 9094 + // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. + cfg.WAFGatewayExtensionEnabled = true component := kubecontrollers.NewCalicoKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -412,7 +504,7 @@ var _ = Describe("kube-controllers rendering tests", func() { envs := dp.Spec.Template.Spec.Containers[0].Env Expect(envs).To(ContainElement(corev1.EnvVar{ Name: "ENABLED_CONTROLLERS", - Value: "node,loadbalancer,service,federatedservices,usage", + Value: "node,loadbalancer,service,federatedservices,usage,applicationlayer", })) Expect(len(dp.Spec.Template.Spec.Containers[0].VolumeMounts)).To(Equal(1)) @@ -512,6 +604,50 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(dp.Spec.Template.Spec.Containers[0].Image).To(Equal("test-reg/tigera/calico:" + components.ComponentTigeraCalico.Version)) }) + It("should mount the WAF admission webhook serving cert and expose its port when WAF is enabled", func() { + certificateManager, err := certificatemanager.Create(cli, nil, dns.DefaultClusterDomain, common.OperatorNamespace(), certificatemanager.AllowCACreation()) + Expect(err).NotTo(HaveOccurred()) + wafTLS, err := certificateManager.GetOrCreateKeyPair(cli, + applicationlayer.WAFWebhookServerTLSSecretName, + common.OperatorNamespace(), + dns.GetServiceDNSNames(applicationlayer.WAFWebhookServiceName, common.CalicoNamespace, dns.DefaultClusterDomain)) + Expect(err).NotTo(HaveOccurred()) + + instance.Variant = operatorv1.CalicoEnterprise + cfg.WAFGatewayExtensionEnabled = true + cfg.WAFWebhookServerTLS = wafTLS + + component := kubecontrollers.NewCalicoKubeControllers(&cfg) + Expect(component.ResolveImages(nil)).To(BeNil()) + resources, _ := component.Objects() + + dp := rtest.GetResource(resources, kubecontrollers.KubeController, common.CalicoNamespace, "apps", "v1", "Deployment").(*appsv1.Deployment) + c := dp.Spec.Template.Spec.Containers[0] + + // Serving cert is mounted and advertised to the in-process webhook server. + Expect(dp.Spec.Template.Spec.Volumes).To(ContainElement(wafTLS.Volume())) + Expect(c.VolumeMounts).To(ContainElement(wafTLS.VolumeMount(rmeta.OSTypeLinux))) + Expect(c.Env).To(ContainElement(corev1.EnvVar{ + Name: "WAF_WEBHOOK_CERT_DIR", + Value: filepath.Dir(wafTLS.VolumeMountCertificateFilePath()), + })) + + // In-process webhook port exposed for the tigera-waf-webhook Service. + Expect(c.Ports).To(ContainElement(corev1.ContainerPort{ + Name: "waf-webhook", + ContainerPort: int32(9443), + Protocol: corev1.ProtocolTCP, + })) + + // namespaces patch/update RBAC for the waf-id-range annotation. + clusterRole := rtest.GetResource(resources, "calico-kube-controllers", "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) + Expect(clusterRole.Rules).To(ContainElement(rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "patch", "update"}, + })) + }) + It("should render all es-calico-kube-controllers resources for a default configuration using CalicoEnterprise and ClusterType is Management", func() { expectedResources := []struct { name string @@ -536,6 +672,8 @@ var _ = Describe("kube-controllers rendering tests", func() { cfg.ManagementCluster = &operatorv1.ManagementCluster{} cfg.KubeControllersGatewaySecret = &testutils.KubeControllersUserSecret cfg.MetricsPort = 9094 + // Opt in to the WAF Gateway API add-on so the WAF env vars + RBAC are rendered. + cfg.WAFGatewayExtensionEnabled = true component := kubecontrollers.NewElasticsearchKubeControllers(&cfg) Expect(component.ResolveImages(nil)).To(BeNil()) @@ -569,7 +707,7 @@ var _ = Describe("kube-controllers rendering tests", func() { Expect(dp.Spec.Template.Spec.Containers[0].Image).To(Equal("test-reg/tigera/calico:" + components.ComponentTigeraCalico.Version)) clusterRole := rtest.GetResource(resources, kubecontrollers.EsKubeControllerRole, "", "rbac.authorization.k8s.io", "v1", "ClusterRole").(*rbacv1.ClusterRole) - Expect(clusterRole.Rules).To(HaveLen(26), "cluster role should have 26 rules") + Expect(clusterRole.Rules).To(HaveLen(36), "cluster role should have 36 rules") Expect(clusterRole.Rules).To(ContainElement( rbacv1.PolicyRule{ APIGroups: []string{""},