From 7d0392621ced43d910bf7fba5d56312baf282013 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Fri, 15 May 2026 14:57:45 +0200 Subject: [PATCH 1/7] feat: add validating addmission webhook for ALB Ingress annotations --- .../main.go | 9 + .../deployment.yaml | 10 ++ .../kustomization.yaml | 2 + .../service.yaml | 4 + .../validating-webhook-issuer.yaml | 21 +++ .../validating-webhook.yaml | 22 +++ pkg/alb/ingress/annotations.go | 4 + pkg/alb/ingress/webhook_test.go | 164 ++++++++++++++++++ pkg/alb/ingress/webook.go | 103 +++++++++++ 9 files changed, 339 insertions(+) create mode 100644 deploy/application-load-balancer-controller-manager/validating-webhook-issuer.yaml create mode 100644 deploy/application-load-balancer-controller-manager/validating-webhook.yaml create mode 100644 pkg/alb/ingress/webhook_test.go create mode 100644 pkg/alb/ingress/webook.go diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 735ed522..0e09b84e 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -19,6 +19,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ) var ( @@ -133,6 +135,13 @@ func main() { os.Exit(1) } + mgr.GetWebhookServer().Register("/validate-ingress", &webhook.Admission{ + Handler: &ingress.IngressValidator{ + Client: mgr.GetClient(), + Decoder: admission.NewDecoder(mgr.GetScheme()), + }, + }) + setupLog.Info("starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { setupLog.Error(err, "problem running manager") diff --git a/deploy/application-load-balancer-controller-manager/deployment.yaml b/deploy/application-load-balancer-controller-manager/deployment.yaml index 52e82f59..964b5e6f 100644 --- a/deploy/application-load-balancer-controller-manager/deployment.yaml +++ b/deploy/application-load-balancer-controller-manager/deployment.yaml @@ -43,6 +43,9 @@ spec: hostPort: 8081 name: probe protocol: TCP + - containerPort: 9443 + name: validating-webhook + protocol: TCP resources: limits: cpu: "0.5" @@ -53,7 +56,14 @@ spec: volumeMounts: - mountPath: /etc/serviceaccount name: cloud-secret + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: validating-webhook-cert + readOnly: true volumes: - name: cloud-secret secret: secretName: stackit-cloud-secret + - name: validating-webhook-cert + secret: + secretName: stackit-application-load-balancer-controller-manager-webhook-cert + \ No newline at end of file diff --git a/deploy/application-load-balancer-controller-manager/kustomization.yaml b/deploy/application-load-balancer-controller-manager/kustomization.yaml index 5dff529b..bdc5769f 100644 --- a/deploy/application-load-balancer-controller-manager/kustomization.yaml +++ b/deploy/application-load-balancer-controller-manager/kustomization.yaml @@ -5,3 +5,5 @@ resources: - deployment.yaml - rbac.yaml - service.yaml +- validating-webhook.yaml +- validating-webhook-issuer.yaml diff --git a/deploy/application-load-balancer-controller-manager/service.yaml b/deploy/application-load-balancer-controller-manager/service.yaml index bd4f1d77..3cd029de 100644 --- a/deploy/application-load-balancer-controller-manager/service.yaml +++ b/deploy/application-load-balancer-controller-manager/service.yaml @@ -17,4 +17,8 @@ spec: port: 8080 targetPort: metrics protocol: TCP + - name: validating-webhook + port: 443 + targetPort: 9443 + protocol: TCP type: ClusterIP diff --git a/deploy/application-load-balancer-controller-manager/validating-webhook-issuer.yaml b/deploy/application-load-balancer-controller-manager/validating-webhook-issuer.yaml new file mode 100644 index 00000000..2e9e0367 --- /dev/null +++ b/deploy/application-load-balancer-controller-manager/validating-webhook-issuer.yaml @@ -0,0 +1,21 @@ +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: stackit-application-load-balancer-controller-manager + namespace: kube-system +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: stackit-application-load-balancer-controller-manager-webhook-cert + namespace: kube-system +spec: + dnsNames: + - stackit-application-load-balancer-controller-manager.kube-system.svc + - stackit-application-load-balancer-controller-manager.kube-system.svc.cluster.local + issuerRef: + kind: Issuer + name: stackit-application-load-balancer-controller-manager + secretName: stackit-application-load-balancer-controller-manager-webhook-cert # cert-manager will create this secret \ No newline at end of file diff --git a/deploy/application-load-balancer-controller-manager/validating-webhook.yaml b/deploy/application-load-balancer-controller-manager/validating-webhook.yaml new file mode 100644 index 00000000..d1fa8760 --- /dev/null +++ b/deploy/application-load-balancer-controller-manager/validating-webhook.yaml @@ -0,0 +1,22 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: stackit-application-load-balancer-controller-manager + annotations: + cert-manager.io/inject-ca-from: kube-system/stackit-application-load-balancer-controller-manager-webhook-cert +webhooks: + - name: validate-ingress.stackit.cloud + rules: + - apiGroups: ["networking.k8s.io"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["ingresses"] + scope: "Namespaced" + clientConfig: + service: + namespace: kube-system + name: stackit-application-load-balancer-controller-manager + path: "/validate-ingress" + admissionReviewVersions: ["v1"] + sideEffects: None + timeoutSeconds: 5 \ No newline at end of file diff --git a/pkg/alb/ingress/annotations.go b/pkg/alb/ingress/annotations.go index f2f890fe..4f16bae5 100644 --- a/pkg/alb/ingress/annotations.go +++ b/pkg/alb/ingress/annotations.go @@ -20,6 +20,10 @@ const ( // AnnotationPlanID sets the plan for the ALB. // Can be set on IngressClass. AnnotationPlanID = "alb.stackit.cloud/plan-id" + // AnnotationNetworkMode specifies the network routing mode. + // It currently validates the presence of "NodePort" to ensure backward compatibility for future direct-to-pod routing. + // Can be set on IngressClass. + AnnotationNetworkMode = "alb.stackit.cloud/network-mode" // AnnotationTargetPoolTLSEnabled If true, the application load balancer enables TLS bridging. // It uses the trusted CAs from the operating system for validation. diff --git a/pkg/alb/ingress/webhook_test.go b/pkg/alb/ingress/webhook_test.go new file mode 100644 index 00000000..cecbfb39 --- /dev/null +++ b/pkg/alb/ingress/webhook_test.go @@ -0,0 +1,164 @@ +package ingress + +import ( + "context" + "encoding/json" + "testing" + + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + admissionv1 "k8s.io/api/admission/v1" +) + +func TestIngressValidator_Handle(t *testing.T) { + s := scheme.Scheme + _ = networkingv1.AddToScheme(s) + + managedIngressClassName := "stackit-alb" + managedIngressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{Name: managedIngressClassName}, + Spec: networkingv1.IngressClassSpec{ + Controller: controllerName, + }, + } + + unmanagedIngressClassName := "nginx" + unmanagedIngressClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{Name: unmanagedIngressClassName}, + Spec: networkingv1.IngressClassSpec{ + Controller: "k8s.io/ingress-nginx", + }, + } + + fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(managedIngressClass, unmanagedIngressClass).Build() + decoder := admission.NewDecoder(s) + + validator := &IngressValidator{ + Client: fakeClient, + } + _ = validator.InjectDecoder(decoder) + + tests := []struct { + name string + className *string + annotations map[string]string + expectAllowed bool + }{ + { + name: "Valid Ingress", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + }, + expectAllowed: true, + }, + { + name: "No IngressClass - Should Ignore and Allow", + className: nil, + annotations: map[string]string{}, + expectAllowed: true, + }, + { + name: "Unmanaged IngressClass - Should Ignore and Allow", + className: &unmanagedIngressClassName, + annotations: map[string]string{ + // These are completely invalid for STACKIT ALB, + // but the webhook shouldn't check them because it's unmanaged. + AnnotationNetworkMode: "LoadBalancer", + AnnotationHTTPPort: "potato", + }, + expectAllowed: true, + }, + { + name: "Missing Network Mode", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationHTTPPort: "80", + }, + expectAllowed: false, + }, + { + name: "Invalid Network Mode Value - Must be NodePort", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationNetworkMode: "LoadBalancer", + }, + expectAllowed: false, + }, + { + name: "Invalid Boolean", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationInternal: "not-a-bool", + }, + expectAllowed: false, + }, + { + name: "Invalid Port Number - Out of Range", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationHTTPPort: "99999", + }, + expectAllowed: false, + }, + { + name: "Invalid IP Address", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "300.0.0.1", + }, + expectAllowed: false, + }, + { + name: "Negative TTL", + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationCookiePersistenceTTLSeconds: "-50", + }, + expectAllowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: tt.annotations, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: tt.className, + }, + } + + // Marshal it into JSON to simulate the API server payload + rawIngress, err := json.Marshal(ingress) + if err != nil { + t.Fatalf("Failed to marshal ingress: %v", err) + } + + req := admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Object: runtime.RawExtension{Raw: rawIngress}, + }, + } + + // Execute the webhook + res := validator.Handle(context.TODO(), req) + + if res.Allowed != tt.expectAllowed { + t.Errorf("Expected Allowed=%v, got Allowed=%v. Result Message: %s", + tt.expectAllowed, res.Allowed, res.Result.Message) + } + }) + } +} \ No newline at end of file diff --git a/pkg/alb/ingress/webook.go b/pkg/alb/ingress/webook.go new file mode 100644 index 00000000..79daf888 --- /dev/null +++ b/pkg/alb/ingress/webook.go @@ -0,0 +1,103 @@ +package ingress + +import ( + "fmt" + "context" + "net" + "net/http" + "strconv" + + networkingv1 "k8s.io/api/networking/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type IngressValidator struct { + Client client.Client + Decoder admission.Decoder +} + +func (v *IngressValidator) InjectDecoder(d admission.Decoder) error { + v.Decoder = d + return nil +} + +func (v *IngressValidator) Handle(ctx context.Context, req admission.Request) admission.Response { + ingress := &networkingv1.Ingress{} + if err := v.Decoder.Decode(req, ingress); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if ingress.Spec.IngressClassName == nil { + return admission.Allowed("No ingress class specified; ignoring.") + } + + ingressClass := &networkingv1.IngressClass{} + if err := v.Client.Get(ctx, client.ObjectKey{Name: *ingress.Spec.IngressClassName}, ingressClass); err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + if ingressClass.Spec.Controller != controllerName { + return admission.Allowed("Ingress managed by a different controller; allowing.") + } + + // 1. Network Mode Check. + mode, exists := ingress.Annotations[AnnotationNetworkMode] + if !exists { + return admission.Denied("The annotation '" + AnnotationNetworkMode + "' is mandatory for STACKIT ALB Ingresses.") + } + if mode != "NodePort" { + return admission.Denied(fmt.Sprintf("The annotation '%s' currently only supports the value 'NodePort'.", AnnotationNetworkMode)) + } + + // 2. Validate IP Addresses. + if val, ok := ingress.Annotations[AnnotationExternalIP]; ok { + if net.ParseIP(val) == nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid IP address.", AnnotationExternalIP)) + } + } + + // 3. Validate Booleans. + boolAnnotations := []string{ + AnnotationInternal, + AnnotationTargetPoolTLSEnabled, + AnnotationTargetPoolTLSSkipCertificateValidation, + AnnotationHTTPSOnly, + AnnotationWebSocket, + } + for _, ann := range boolAnnotations { + if val, ok := ingress.Annotations[ann]; ok { + if _, err := strconv.ParseBool(val); err != nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid boolean (true or false).", ann)) + } + } + } + + // 4. Validate Ports (Must be between 1 and 65535). + portAnnotations := []string{AnnotationHTTPPort, AnnotationHTTPSPort} + for _, ann := range portAnnotations { + if val, ok := ingress.Annotations[ann]; ok { + port, err := strconv.Atoi(val) + if err != nil || port < 1 || port > 65535 { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid port number between 1 and 65535.", ann)) + } + } + } + + // 5. Validate TTL and Priority (Must be valid integers. TTL must be non-negative). + intAnnotations := []string{AnnotationCookiePersistenceTTLSeconds, AnnotationPriority} + for _, ann := range intAnnotations { + if val, ok := ingress.Annotations[ann]; ok { + num, err := strconv.Atoi(val) + if err != nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid integer.", ann)) + } + // Optional: Enforce TTL to be non-negative + if ann == AnnotationCookiePersistenceTTLSeconds && num < 0 { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be greater than or equal to 0.", ann)) + } + } + } + + return admission.Allowed("Validation passed.") +} \ No newline at end of file From ae646b28ebfafaded2476982623c1e96dba9b103 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 19 May 2026 13:43:44 +0200 Subject: [PATCH 2/7] feat: add ingressclass webhook --- .../main.go | 17 +- .../deployment.yaml | 6 +- .../service.yaml | 2 +- .../validating-webhook-issuer.yaml | 2 +- .../validating-webhook.yaml | 15 ++ ...ebhook_test.go => ingress_webhook_test.go} | 96 ++++--- .../ingress/{webook.go => ingress_webook.go} | 74 +++--- pkg/alb/ingress/ingressclass_webhook.go | 170 ++++++++++++ pkg/alb/ingress/ingressclass_webhook_test.go | 251 ++++++++++++++++++ 9 files changed, 540 insertions(+), 93 deletions(-) rename pkg/alb/ingress/{webhook_test.go => ingress_webhook_test.go} (66%) rename pkg/alb/ingress/{webook.go => ingress_webook.go} (53%) create mode 100644 pkg/alb/ingress/ingressclass_webhook.go create mode 100644 pkg/alb/ingress/ingressclass_webhook_test.go diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 0e09b84e..425876ba 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -135,12 +135,19 @@ func main() { os.Exit(1) } + decoder := admission.NewDecoder(mgr.GetScheme()) mgr.GetWebhookServer().Register("/validate-ingress", &webhook.Admission{ - Handler: &ingress.IngressValidator{ - Client: mgr.GetClient(), - Decoder: admission.NewDecoder(mgr.GetScheme()), - }, - }) + Handler: &ingress.IngressValidator{ + Client: mgr.GetClient(), + Decoder: decoder, + }, + }) + mgr.GetWebhookServer().Register("/validate-ingressclass", &webhook.Admission{ + Handler: &ingress.IngressClassValidator{ + Client: mgr.GetClient(), + Decoder: decoder, + }, + }) setupLog.Info("starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { diff --git a/deploy/application-load-balancer-controller-manager/deployment.yaml b/deploy/application-load-balancer-controller-manager/deployment.yaml index 964b5e6f..d7c71f8f 100644 --- a/deploy/application-load-balancer-controller-manager/deployment.yaml +++ b/deploy/application-load-balancer-controller-manager/deployment.yaml @@ -44,7 +44,7 @@ spec: name: probe protocol: TCP - containerPort: 9443 - name: validating-webhook + name: webhook protocol: TCP resources: limits: @@ -57,13 +57,13 @@ spec: - mountPath: /etc/serviceaccount name: cloud-secret - mountPath: /tmp/k8s-webhook-server/serving-certs - name: validating-webhook-cert + name: webhook-cert readOnly: true volumes: - name: cloud-secret secret: secretName: stackit-cloud-secret - - name: validating-webhook-cert + - name: webhook-cert secret: secretName: stackit-application-load-balancer-controller-manager-webhook-cert \ No newline at end of file diff --git a/deploy/application-load-balancer-controller-manager/service.yaml b/deploy/application-load-balancer-controller-manager/service.yaml index 3cd029de..1fe65be8 100644 --- a/deploy/application-load-balancer-controller-manager/service.yaml +++ b/deploy/application-load-balancer-controller-manager/service.yaml @@ -17,7 +17,7 @@ spec: port: 8080 targetPort: metrics protocol: TCP - - name: validating-webhook + - name: webhook port: 443 targetPort: 9443 protocol: TCP diff --git a/deploy/application-load-balancer-controller-manager/validating-webhook-issuer.yaml b/deploy/application-load-balancer-controller-manager/validating-webhook-issuer.yaml index 2e9e0367..c5d43871 100644 --- a/deploy/application-load-balancer-controller-manager/validating-webhook-issuer.yaml +++ b/deploy/application-load-balancer-controller-manager/validating-webhook-issuer.yaml @@ -18,4 +18,4 @@ spec: issuerRef: kind: Issuer name: stackit-application-load-balancer-controller-manager - secretName: stackit-application-load-balancer-controller-manager-webhook-cert # cert-manager will create this secret \ No newline at end of file + secretName: stackit-application-load-balancer-controller-manager-webhook-cert # cert-manager will create this secret diff --git a/deploy/application-load-balancer-controller-manager/validating-webhook.yaml b/deploy/application-load-balancer-controller-manager/validating-webhook.yaml index d1fa8760..4f48d2b9 100644 --- a/deploy/application-load-balancer-controller-manager/validating-webhook.yaml +++ b/deploy/application-load-balancer-controller-manager/validating-webhook.yaml @@ -19,4 +19,19 @@ webhooks: path: "/validate-ingress" admissionReviewVersions: ["v1"] sideEffects: None + timeoutSeconds: 5 + - name: validate-ingressclass.stackit.cloud + rules: + - apiGroups: ["networking.k8s.io"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["ingressclasses"] + scope: "Cluster" + clientConfig: + service: + namespace: kube-system + name: stackit-application-load-balancer-controller-manager + path: "/validate-ingressclass" + admissionReviewVersions: ["v1"] + sideEffects: None timeoutSeconds: 5 \ No newline at end of file diff --git a/pkg/alb/ingress/webhook_test.go b/pkg/alb/ingress/ingress_webhook_test.go similarity index 66% rename from pkg/alb/ingress/webhook_test.go rename to pkg/alb/ingress/ingress_webhook_test.go index cecbfb39..14b73cf8 100644 --- a/pkg/alb/ingress/webhook_test.go +++ b/pkg/alb/ingress/ingress_webhook_test.go @@ -5,13 +5,13 @@ import ( "encoding/json" "testing" + admissionv1 "k8s.io/api/admission/v1" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" - admissionv1 "k8s.io/api/admission/v1" ) func TestIngressValidator_Handle(t *testing.T) { @@ -38,89 +38,76 @@ func TestIngressValidator_Handle(t *testing.T) { decoder := admission.NewDecoder(s) validator := &IngressValidator{ - Client: fakeClient, + Client: fakeClient, + Decoder: decoder, } - _ = validator.InjectDecoder(decoder) tests := []struct { - name string - className *string - annotations map[string]string - expectAllowed bool + name string + operation admissionv1.Operation + className *string + annotations map[string]string + expectAllowed bool }{ { - name: "Valid Ingress", + name: "Valid Ingress (Create)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationHTTPSOnly: "true", + AnnotationPriority: "100", + }, + expectAllowed: true, + }, + { + name: "Valid Ingress (Update)", + operation: admissionv1.Update, className: &managedIngressClassName, annotations: map[string]string{ - AnnotationNetworkMode: "NodePort", + AnnotationHTTPSOnly: "false", + AnnotationCookiePersistenceTTLSeconds: "3600", }, expectAllowed: true, }, { name: "No IngressClass - Should Ignore and Allow", + operation: admissionv1.Create, className: nil, annotations: map[string]string{}, expectAllowed: true, }, { name: "Unmanaged IngressClass - Should Ignore and Allow", + operation: admissionv1.Create, className: &unmanagedIngressClassName, annotations: map[string]string{ - // These are completely invalid for STACKIT ALB, - // but the webhook shouldn't check them because it's unmanaged. - AnnotationNetworkMode: "LoadBalancer", - AnnotationHTTPPort: "potato", + AnnotationHTTPSOnly: "not-a-bool", }, expectAllowed: true, }, { - name: "Missing Network Mode", - className: &managedIngressClassName, - annotations: map[string]string{ - AnnotationHTTPPort: "80", - }, - expectAllowed: false, - }, - { - name: "Invalid Network Mode Value - Must be NodePort", - className: &managedIngressClassName, - annotations: map[string]string{ - AnnotationNetworkMode: "LoadBalancer", - }, - expectAllowed: false, - }, - { - name: "Invalid Boolean", + name: "Denied - Invalid Boolean", + operation: admissionv1.Create, className: &managedIngressClassName, annotations: map[string]string{ - AnnotationNetworkMode: "NodePort", - AnnotationInternal: "not-a-bool", + AnnotationHTTPSOnly: "not-a-bool", }, expectAllowed: false, }, { - name: "Invalid Port Number - Out of Range", + name: "Denied - Invalid Integer", + operation: admissionv1.Create, className: &managedIngressClassName, annotations: map[string]string{ - AnnotationNetworkMode: "NodePort", - AnnotationHTTPPort: "99999", + AnnotationPriority: "high", }, expectAllowed: false, }, { - name: "Invalid IP Address", + name: "Denied - Negative TTL", + operation: admissionv1.Create, className: &managedIngressClassName, annotations: map[string]string{ - AnnotationNetworkMode: "NodePort", - AnnotationExternalIP: "300.0.0.1", - }, - expectAllowed: false, - }, - { - name: "Negative TTL", - className: &managedIngressClassName, - annotations: map[string]string{ - AnnotationNetworkMode: "NodePort", AnnotationCookiePersistenceTTLSeconds: "-50", }, expectAllowed: false, @@ -130,6 +117,10 @@ func TestIngressValidator_Handle(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ingress := &networkingv1.Ingress{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "networking.k8s.io/v1", + Kind: "Ingress", + }, ObjectMeta: metav1.ObjectMeta{ Name: "test-ingress", Namespace: "default", @@ -139,8 +130,7 @@ func TestIngressValidator_Handle(t *testing.T) { IngressClassName: tt.className, }, } - - // Marshal it into JSON to simulate the API server payload + rawIngress, err := json.Marshal(ingress) if err != nil { t.Fatalf("Failed to marshal ingress: %v", err) @@ -148,15 +138,19 @@ func TestIngressValidator_Handle(t *testing.T) { req := admission.Request{ AdmissionRequest: admissionv1.AdmissionRequest{ - Object: runtime.RawExtension{Raw: rawIngress}, + Operation: tt.operation, + Object: runtime.RawExtension{Raw: rawIngress}, }, } - // Execute the webhook + if tt.operation == admissionv1.Update { + req.AdmissionRequest.OldObject = runtime.RawExtension{Raw: rawIngress} + } + res := validator.Handle(context.TODO(), req) if res.Allowed != tt.expectAllowed { - t.Errorf("Expected Allowed=%v, got Allowed=%v. Result Message: %s", + t.Errorf("Expected Allowed=%v, got Allowed=%v. Result Message: %s", tt.expectAllowed, res.Allowed, res.Result.Message) } }) diff --git a/pkg/alb/ingress/webook.go b/pkg/alb/ingress/ingress_webook.go similarity index 53% rename from pkg/alb/ingress/webook.go rename to pkg/alb/ingress/ingress_webook.go index 79daf888..e7207d35 100644 --- a/pkg/alb/ingress/webook.go +++ b/pkg/alb/ingress/ingress_webook.go @@ -3,13 +3,13 @@ package ingress import ( "fmt" "context" - "net" "net/http" "strconv" networkingv1 "k8s.io/api/networking/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + admissionv1 "k8s.io/api/admission/v1" ) type IngressValidator struct { @@ -17,12 +17,19 @@ type IngressValidator struct { Decoder admission.Decoder } -func (v *IngressValidator) InjectDecoder(d admission.Decoder) error { - v.Decoder = d - return nil +// Handle routes the request based on the operation type. +func (v *IngressValidator) Handle(ctx context.Context, req admission.Request) admission.Response { + switch req.Operation { + case admissionv1.Create: + return v.handleCreate(ctx, req) + case admissionv1.Update: + return v.handleUpdate(ctx, req) + default: + return admission.Allowed("Unhandled operation allowed.") + } } -func (v *IngressValidator) Handle(ctx context.Context, req admission.Request) admission.Response { +func (v *IngressValidator) handleCreate(ctx context.Context, req admission.Request) admission.Response { ingress := &networkingv1.Ingress{} if err := v.Decoder.Decode(req, ingress); err != nil { return admission.Errored(http.StatusBadRequest, err) @@ -36,30 +43,45 @@ func (v *IngressValidator) Handle(ctx context.Context, req admission.Request) ad if err := v.Client.Get(ctx, client.ObjectKey{Name: *ingress.Spec.IngressClassName}, ingressClass); err != nil { return admission.Errored(http.StatusInternalServerError, err) } - + if ingressClass.Spec.Controller != controllerName { return admission.Allowed("Ingress managed by a different controller; allowing.") } - // 1. Network Mode Check. - mode, exists := ingress.Annotations[AnnotationNetworkMode] - if !exists { - return admission.Denied("The annotation '" + AnnotationNetworkMode + "' is mandatory for STACKIT ALB Ingresses.") + return v.validateBaseAnnotations(ctx, ingress) +} + +func (v *IngressValidator) handleUpdate(ctx context.Context, req admission.Request) admission.Response { + newIngress := &networkingv1.Ingress{} + if err := v.Decoder.Decode(req, newIngress); err != nil { + return admission.Errored(http.StatusBadRequest, err) } - if mode != "NodePort" { - return admission.Denied(fmt.Sprintf("The annotation '%s' currently only supports the value 'NodePort'.", AnnotationNetworkMode)) + + oldIngress := &networkingv1.Ingress{} + if err := v.Decoder.DecodeRaw(req.OldObject, oldIngress); err != nil { + return admission.Errored(http.StatusBadRequest, err) } - // 2. Validate IP Addresses. - if val, ok := ingress.Annotations[AnnotationExternalIP]; ok { - if net.ParseIP(val) == nil { - return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid IP address.", AnnotationExternalIP)) - } + if newIngress.Spec.IngressClassName == nil { + return admission.Allowed("No ingress class specified; ignoring.") + } + + ingressClass := &networkingv1.IngressClass{} + if err := v.Client.Get(ctx, client.ObjectKey{Name: *newIngress.Spec.IngressClassName}, ingressClass); err != nil { + return admission.Errored(http.StatusInternalServerError, err) } - // 3. Validate Booleans. + if ingressClass.Spec.Controller != controllerName { + return admission.Allowed("Ingress managed by a different controller; allowing.") + } + + return v.validateBaseAnnotations(ctx, newIngress) +} + +// validateBaseAnnotations checks simple formatting, allowed values, and basic constraints for all relevant annotations. +func (v *IngressValidator) validateBaseAnnotations(ctx context.Context, ingress *networkingv1.Ingress) admission.Response { + // Validate Booleans boolAnnotations := []string{ - AnnotationInternal, AnnotationTargetPoolTLSEnabled, AnnotationTargetPoolTLSSkipCertificateValidation, AnnotationHTTPSOnly, @@ -73,18 +95,7 @@ func (v *IngressValidator) Handle(ctx context.Context, req admission.Request) ad } } - // 4. Validate Ports (Must be between 1 and 65535). - portAnnotations := []string{AnnotationHTTPPort, AnnotationHTTPSPort} - for _, ann := range portAnnotations { - if val, ok := ingress.Annotations[ann]; ok { - port, err := strconv.Atoi(val) - if err != nil || port < 1 || port > 65535 { - return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid port number between 1 and 65535.", ann)) - } - } - } - - // 5. Validate TTL and Priority (Must be valid integers. TTL must be non-negative). + // Validate Integers and TTL limits intAnnotations := []string{AnnotationCookiePersistenceTTLSeconds, AnnotationPriority} for _, ann := range intAnnotations { if val, ok := ingress.Annotations[ann]; ok { @@ -92,7 +103,6 @@ func (v *IngressValidator) Handle(ctx context.Context, req admission.Request) ad if err != nil { return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid integer.", ann)) } - // Optional: Enforce TTL to be non-negative if ann == AnnotationCookiePersistenceTTLSeconds && num < 0 { return admission.Denied(fmt.Sprintf("Annotation '%s' must be greater than or equal to 0.", ann)) } diff --git a/pkg/alb/ingress/ingressclass_webhook.go b/pkg/alb/ingress/ingressclass_webhook.go new file mode 100644 index 00000000..5c211c0b --- /dev/null +++ b/pkg/alb/ingress/ingressclass_webhook.go @@ -0,0 +1,170 @@ +package ingress + +import ( + "fmt" + "context" + "net" + "net/http" + "strconv" + + networkingv1 "k8s.io/api/networking/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + admissionv1 "k8s.io/api/admission/v1" +) + +type IngressClassValidator struct { + Client client.Client + Decoder admission.Decoder +} + +func (v *IngressClassValidator) Handle(ctx context.Context, req admission.Request) admission.Response { + switch req.Operation { + case admissionv1.Create: + return v.handleCreate(ctx, req) + case admissionv1.Update: + return v.handleUpdate(ctx, req) + default: + return admission.Allowed("Unhandled operation allowed.") + } +} + +func (v *IngressClassValidator) handleCreate(ctx context.Context, req admission.Request) admission.Response { + newClass := &networkingv1.IngressClass{} + if err := v.Decoder.Decode(req, newClass); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if newClass.Spec.Controller != controllerName { + return admission.Allowed("Not a STACKIT ALB IngressClass.") + } + + if val, ok := newClass.Annotations[AnnotationExternalIP]; ok { + if net.ParseIP(val) == nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid IP address.", AnnotationExternalIP)) + } + } + + if resp := v.validateBaseAnnotations(newClass); !resp.Allowed { + return resp + } + + return admission.Allowed("IngressClass creation valid.") +} + +func (v *IngressClassValidator) handleUpdate(ctx context.Context, req admission.Request) admission.Response { + oldClass := &networkingv1.IngressClass{} + if err := v.Decoder.DecodeRaw(req.OldObject, oldClass); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + newClass := &networkingv1.IngressClass{} + if err := v.Decoder.Decode(req, newClass); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if newClass.Spec.Controller != controllerName { + return admission.Allowed("Not a STACKIT ALB IngressClass.") + } + + if val, ok := newClass.Annotations[AnnotationExternalIP]; ok { + if net.ParseIP(val) == nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid IP address.", AnnotationExternalIP)) + } + } + + if resp := v.validateBaseAnnotations(newClass); !resp.Allowed { + return resp + } + + if resp := v.validateIPUpdate(ctx, oldClass, newClass); !resp.Allowed { + return resp + } + + return admission.Allowed("IngressClass creation valid.") +} + +// validateBaseAnnotations checks simple formatting and allowed values for IngressClass annotations. +func (v *IngressClassValidator) validateBaseAnnotations(ingressClass *networkingv1.IngressClass) admission.Response { + if val, ok := ingressClass.Annotations[AnnotationExternalIP]; ok { + if net.ParseIP(val) == nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid IP address.", AnnotationExternalIP)) + } + } + + // Network Mode Check. + mode, exists := ingressClass.Annotations[AnnotationNetworkMode] + if !exists { + return admission.Denied("The annotation '" + AnnotationNetworkMode + "' is mandatory for STACKIT ALB IngressClasses.") + } + if mode != "NodePort" { + return admission.Denied(fmt.Sprintf("The annotation '%s' currently only supports the value 'NodePort'.", AnnotationNetworkMode)) + } + + // Service Plan Check. + if val, ok := ingressClass.Annotations[AnnotationPlanID]; ok { + switch val { + case "p10", "p50", "p250", "p750": + default: + return admission.Denied(fmt.Sprintf("Annotation '%s' has an invalid value '%s'. Allowed values are: p10, p50, p250, p750.", AnnotationPlanID, val)) + } + } + + // Validate Listener Ports (Must be between 1 and 65535) + portAnnotations := []string{AnnotationHTTPPort, AnnotationHTTPSPort} + for _, ann := range portAnnotations { + if val, ok := ingressClass.Annotations[ann]; ok { + port, err := strconv.Atoi(val) + if err != nil || port < 1 || port > 65535 { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid port number between 1 and 65535.", ann)) + } + } + } + + return admission.Allowed("Base annotations valid.") +} + +// validateIPUpdate enforces the strict update rules for the external IP annotation. +func (v *IngressClassValidator) validateIPUpdate(ctx context.Context, oldClass, newClass *networkingv1.IngressClass) admission.Response { + oldIP, oldHadIP := oldClass.Annotations[AnnotationExternalIP] + newIP, newHasIP := newClass.Annotations[AnnotationExternalIP] + + if oldHadIP && newHasIP && oldIP != newIP { + errMsg := fmt.Sprintf("Changing an existing static IP address is not allowed. The annotation '%s' cannot be updated from '%s' to '%s'.", + AnnotationExternalIP, oldIP, newIP) + return admission.Denied(errMsg) + } + + if !oldHadIP && newHasIP { + currentAssignedIP, err := v.getAssignedEphemeralIP(ctx, newClass.Name) + if err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + if currentAssignedIP == "" || currentAssignedIP != newIP { + errMsg := fmt.Sprintf("The load balancer can only be promoted to a static IP address that matches its current ephemeral IP (currently assigned: '%s', requested: '%s').", + currentAssignedIP, newIP) + return admission.Denied(errMsg) + } + } + + return admission.Allowed("IngressClass IP update valid.") +} + +// getAssignedEphemeralIP scans the cluster for an Ingress using this class and returns its assigned IP. +func (v *IngressClassValidator) getAssignedEphemeralIP(ctx context.Context, className string) (string, error) { + ingressList := &networkingv1.IngressList{} + if err := v.Client.List(ctx, ingressList); err != nil { + return "", err + } + + for _, ing := range ingressList.Items { + if ing.Spec.IngressClassName != nil && *ing.Spec.IngressClassName == className { + if len(ing.Status.LoadBalancer.Ingress) > 0 { + return ing.Status.LoadBalancer.Ingress[0].IP, nil + } + } + } + + return "", nil +} \ No newline at end of file diff --git a/pkg/alb/ingress/ingressclass_webhook_test.go b/pkg/alb/ingress/ingressclass_webhook_test.go new file mode 100644 index 00000000..8557d9f9 --- /dev/null +++ b/pkg/alb/ingress/ingressclass_webhook_test.go @@ -0,0 +1,251 @@ +package ingress + +import ( + "context" + "encoding/json" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestIngressClassValidator_Handle(t *testing.T) { + s := scheme.Scheme + _ = networkingv1.AddToScheme(s) + + decoder := admission.NewDecoder(s) + managedController := controllerName + unmanagedController := "k8s.io/ingress-nginx" + testClassName := "test-class" + + tests := []struct { + name string + operation admissionv1.Operation + controller string + oldAnnotations map[string]string + newAnnotations map[string]string + ephemeralIP string + expectAllowed bool + }{ + { + name: "Valid IngressClass Create", + operation: admissionv1.Create, + controller: managedController, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationPlanID: "p50", + AnnotationHTTPPort: "80", + }, + expectAllowed: true, + }, + { + name: "Unmanaged Controller - Should Ignore and Allow", + operation: admissionv1.Create, + controller: unmanagedController, + newAnnotations: map[string]string{ + AnnotationPlanID: "invalid-plan", + }, + expectAllowed: true, + }, + { + name: "Missing Network Mode (Denied)", + operation: admissionv1.Create, + controller: managedController, + newAnnotations: map[string]string{ + AnnotationPlanID: "p50", + }, + expectAllowed: false, + }, + { + name: "Invalid Network Mode Value (Denied)", + operation: admissionv1.Create, + controller: managedController, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "LoadBalancer", + }, + expectAllowed: false, + }, + { + name: "Invalid IP Address Format (Denied)", + operation: admissionv1.Create, + controller: managedController, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "not-an-ip-address", + }, + expectAllowed: false, + }, + { + name: "Invalid Plan ID (Denied)", + operation: admissionv1.Create, + controller: managedController, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationPlanID: "p100", + }, + expectAllowed: false, + }, + { + name: "Invalid Port (Denied)", + operation: admissionv1.Create, + controller: managedController, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationHTTPPort: "99999", + }, + expectAllowed: false, + }, + + { + name: "Update - Keep same Static IP (Allowed)", + operation: admissionv1.Update, + controller: managedController, + oldAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "1.2.3.4", + }, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "1.2.3.4", + }, + expectAllowed: true, + }, + { + name: "Update - Change existing Static IP (Denied)", + operation: admissionv1.Update, + controller: managedController, + oldAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "1.2.3.4", + }, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "5.6.7.8", + }, + expectAllowed: false, + }, + { + name: "Update - Promote Ephemeral to Static (Matches - Allowed)", + operation: admissionv1.Update, + controller: managedController, + oldAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + }, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "9.9.9.9", + }, + ephemeralIP: "9.9.9.9", + expectAllowed: true, + }, + { + name: "Update - Promote Ephemeral to Static (Mismatch - Denied)", + operation: admissionv1.Update, + controller: managedController, + oldAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + }, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "8.8.8.8", + }, + ephemeralIP: "9.9.9.9", + expectAllowed: false, + }, + { + name: "Update - Promote Ephemeral to Static (No IP Assigned Yet - Denied)", + operation: admissionv1.Update, + controller: managedController, + oldAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + }, + newAnnotations: map[string]string{ + AnnotationNetworkMode: "NodePort", + AnnotationExternalIP: "8.8.8.8", + }, + ephemeralIP: "", + expectAllowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clientBuilder := fake.NewClientBuilder().WithScheme(s) + + if tt.ephemeralIP != "" { + mockIngress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-ingress", + Namespace: "default", + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: &testClassName, + }, + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: tt.ephemeralIP}, + }, + }, + }, + } + clientBuilder.WithObjects(mockIngress) + } + + validator := &IngressClassValidator{ + Client: clientBuilder.Build(), + Decoder: decoder, + } + + newClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testClassName, + Annotations: tt.newAnnotations, + }, + Spec: networkingv1.IngressClassSpec{ + Controller: tt.controller, + }, + } + rawNew, err := json.Marshal(newClass) + if err != nil { + t.Fatalf("Failed to marshal new IngressClass: %v", err) + } + + req := admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: tt.operation, + Object: runtime.RawExtension{Raw: rawNew}, + }, + } + + if tt.operation == admissionv1.Update { + oldClass := &networkingv1.IngressClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: testClassName, + Annotations: tt.oldAnnotations, + }, + Spec: networkingv1.IngressClassSpec{ + Controller: tt.controller, + }, + } + rawOld, err := json.Marshal(oldClass) + if err != nil { + t.Fatalf("Failed to marshal old IngressClass: %v", err) + } + req.AdmissionRequest.OldObject = runtime.RawExtension{Raw: rawOld} + } + + res := validator.Handle(context.TODO(), req) + + if res.Allowed != tt.expectAllowed { + t.Errorf("Expected Allowed=%v, got Allowed=%v. Result Message: %s", + tt.expectAllowed, res.Allowed, res.Result.Message) + } + }) + } +} \ No newline at end of file From efba9a28f565fafdf97182640956c582c8b858a9 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 19 May 2026 14:17:31 +0200 Subject: [PATCH 3/7] feat: check for AnnotationInternal immutability --- pkg/alb/ingress/ingressclass_webhook.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/alb/ingress/ingressclass_webhook.go b/pkg/alb/ingress/ingressclass_webhook.go index 5c211c0b..d4ed9a74 100644 --- a/pkg/alb/ingress/ingressclass_webhook.go +++ b/pkg/alb/ingress/ingressclass_webhook.go @@ -77,6 +77,13 @@ func (v *IngressClassValidator) handleUpdate(ctx context.Context, req admission. return resp } + // Check immutability for AnnotationInternal + oldInternal := oldClass.Annotations[AnnotationInternal] + newInternal := newClass.Annotations[AnnotationInternal] + if oldInternal != newInternal { + return admission.Denied(fmt.Sprintf("The annotation '%s' is immutable and cannot be changed after creation.", AnnotationInternal)) + } + if resp := v.validateIPUpdate(ctx, oldClass, newClass); !resp.Allowed { return resp } @@ -92,6 +99,13 @@ func (v *IngressClassValidator) validateBaseAnnotations(ingressClass *networking } } + // Check if AnnotationInternal is a boolean. + if val, ok := ingressClass.Annotations[AnnotationInternal]; ok { + if _, err := strconv.ParseBool(val); err != nil { + return admission.Denied(fmt.Sprintf("Annotation '%s' must be a valid boolean (true or false).", AnnotationInternal)) + } + } + // Network Mode Check. mode, exists := ingressClass.Annotations[AnnotationNetworkMode] if !exists { From 8133cd808aff05e4ec59f85b2ac620e99997abe9 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 19 May 2026 15:29:10 +0200 Subject: [PATCH 4/7] feat(webhook): validate AnnotationWebSocket --- pkg/alb/ingress/ingress_webhook_test.go | 63 +++++++++++++++++++++++++ pkg/alb/ingress/ingress_webook.go | 10 ++++ 2 files changed, 73 insertions(+) diff --git a/pkg/alb/ingress/ingress_webhook_test.go b/pkg/alb/ingress/ingress_webhook_test.go index 14b73cf8..0e91d630 100644 --- a/pkg/alb/ingress/ingress_webhook_test.go +++ b/pkg/alb/ingress/ingress_webhook_test.go @@ -112,6 +112,69 @@ func TestIngressValidator_Handle(t *testing.T) { }, expectAllowed: false, }, + { + name: "Valid WAF Name (Allowed)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "my-valid-waf-123", + }, + expectAllowed: true, + }, + { + name: "Valid WAF Name Single Char (Allowed)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "a", + }, + expectAllowed: true, + }, + { + name: "Denied - Invalid WAF Name (Uppercase)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "My-Waf-Name", + }, + expectAllowed: false, + }, + { + name: "Denied - Invalid WAF Name (Starts with hyphen)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "-my-waf", + }, + expectAllowed: false, + }, + { + name: "Denied - Invalid WAF Name (Ends with hyphen)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "my-waf-", + }, + expectAllowed: false, + }, + { + name: "Denied - Invalid WAF Name (Invalid Character)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "my_waf_name", + }, + expectAllowed: false, + }, + { + name: "Denied - Invalid WAF Name (Too Long - 64 chars)", + operation: admissionv1.Create, + className: &managedIngressClassName, + annotations: map[string]string{ + AnnotationWAFName: "a123456789012345678901234567890123456789012345678901234567890123", + }, + expectAllowed: false, + }, } for _, tt := range tests { diff --git a/pkg/alb/ingress/ingress_webook.go b/pkg/alb/ingress/ingress_webook.go index e7207d35..1eb4ddab 100644 --- a/pkg/alb/ingress/ingress_webook.go +++ b/pkg/alb/ingress/ingress_webook.go @@ -5,6 +5,7 @@ import ( "context" "net/http" "strconv" + "regexp" networkingv1 "k8s.io/api/networking/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -80,6 +81,15 @@ func (v *IngressValidator) handleUpdate(ctx context.Context, req admission.Reque // validateBaseAnnotations checks simple formatting, allowed values, and basic constraints for all relevant annotations. func (v *IngressValidator) validateBaseAnnotations(ctx context.Context, ingress *networkingv1.Ingress) admission.Response { + // Validate WAF Name using the provided regex constraint + if val, ok := ingress.Annotations[AnnotationWAFName]; ok { + wafRegex := `^[0-9a-z](?:(?:[0-9a-z]|-){0,61}[0-9a-z])?$` + matched, _ := regexp.MatchString(wafRegex, val) + if !matched { + return admission.Denied(fmt.Sprintf("Annotation '%s' has an invalid value '%s'. It must match the pattern: %s", AnnotationWAFName, val, wafRegex)) + } + } + // Validate Booleans boolAnnotations := []string{ AnnotationTargetPoolTLSEnabled, From edf3c80897c4a3fabd3b625b0ff822c324533fe7 Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 19 May 2026 18:47:15 +0200 Subject: [PATCH 5/7] fix(webhook): use uncached API reader --- cmd/application-load-balancer-controller-manager/main.go | 4 ++-- pkg/alb/ingress/ingress_webook.go | 2 +- pkg/alb/ingress/ingressclass_webhook.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/application-load-balancer-controller-manager/main.go b/cmd/application-load-balancer-controller-manager/main.go index 425876ba..950280f8 100644 --- a/cmd/application-load-balancer-controller-manager/main.go +++ b/cmd/application-load-balancer-controller-manager/main.go @@ -138,13 +138,13 @@ func main() { decoder := admission.NewDecoder(mgr.GetScheme()) mgr.GetWebhookServer().Register("/validate-ingress", &webhook.Admission{ Handler: &ingress.IngressValidator{ - Client: mgr.GetClient(), + Client: mgr.GetAPIReader(), Decoder: decoder, }, }) mgr.GetWebhookServer().Register("/validate-ingressclass", &webhook.Admission{ Handler: &ingress.IngressClassValidator{ - Client: mgr.GetClient(), + Client: mgr.GetAPIReader(), Decoder: decoder, }, }) diff --git a/pkg/alb/ingress/ingress_webook.go b/pkg/alb/ingress/ingress_webook.go index 1eb4ddab..01eede5f 100644 --- a/pkg/alb/ingress/ingress_webook.go +++ b/pkg/alb/ingress/ingress_webook.go @@ -14,7 +14,7 @@ import ( ) type IngressValidator struct { - Client client.Client + Client client.Reader Decoder admission.Decoder } diff --git a/pkg/alb/ingress/ingressclass_webhook.go b/pkg/alb/ingress/ingressclass_webhook.go index d4ed9a74..895134de 100644 --- a/pkg/alb/ingress/ingressclass_webhook.go +++ b/pkg/alb/ingress/ingressclass_webhook.go @@ -14,7 +14,7 @@ import ( ) type IngressClassValidator struct { - Client client.Client + Client client.Reader Decoder admission.Decoder } From 17a8d57a0e4cb133058ebb8ab8134e5ec595484c Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Thu, 21 May 2026 16:39:49 +0200 Subject: [PATCH 6/7] fix: allow api server to webhook pod traffic --- .../deployment.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deploy/application-load-balancer-controller-manager/deployment.yaml b/deploy/application-load-balancer-controller-manager/deployment.yaml index d7c71f8f..ca490539 100644 --- a/deploy/application-load-balancer-controller-manager/deployment.yaml +++ b/deploy/application-load-balancer-controller-manager/deployment.yaml @@ -12,6 +12,9 @@ spec: selector: matchLabels: app: stackit-application-load-balancer-controller-manager + networking.gardener.cloud/from-seed: allowed # Allow traffic from seed to shoot for webhook calls + networking.gardener.cloud/to-dns: allowed # Allow traffic to CoreDNS for webhook calls + networking.gardener.cloud/to-apiserver: allowed # Allow traffic to API server for webhook calls template: metadata: labels: From 5bdec6303e782e14be03a135c8f17412a575155e Mon Sep 17 00:00:00 2001 From: Kamil Przybyl Date: Tue, 2 Jun 2026 19:38:51 +0200 Subject: [PATCH 7/7] chore: add network mode annotation --- docs/albcm.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/albcm.md b/docs/albcm.md index 3e32d9ef..df31715e 100644 --- a/docs/albcm.md +++ b/docs/albcm.md @@ -35,6 +35,13 @@ If you need to explicitly prioritize certain rules over others, you can override Note that this sorting only applies across different Ingress resources. The top-to-bottom sequence of rules and paths defined within a single Ingress YAML is not preserved and is processed non-deterministically. If you need to preserve the exact top-to-bottom order specified in your YAML, you must separate them into distinct Ingress resources and use the priority annotation. +### Network Routing Mode +The network routing mode determines how the ALB forwards external traffic to your applications within the cluster. Specifying this mode is mandatory. If omitted, your resource will be rejected by the validation webhook. + +You must set the `alb.stackit.cloud/network-mode` annotation on your IngressClass. + +Currently `NodePort` is the only supported mode. The ALB routes traffic to the cluster nodes, which then forward it to your pods. Future releases will introduce direct-to-pod routing as an additional mode. Setting `NodePort` now ensures your current configurations remain backwards compatible when new modes are added. + ### WebSockets Support You can enable WebSocket support for your applications by adding a specific annotation to your Ingress resource. Note that in this initial release, enabling this annotation applies globally to all routing rules defined within that specific Ingress. @@ -69,6 +76,7 @@ metadata: | Annotation | Allowed On | Requirement | Description | | :--- | :--- | :--- | :--- | +| `alb.stackit.cloud/network-mode` | IngressClass | Mandatory | Routing mode (currently only `NodePort` supported). | | `alb.stackit.cloud/external-address` | IngressClass | Optional | Uses a specific STACKIT floating IP instead of an ephemeral one. | | `alb.stackit.cloud/internal` | IngressClass | Optional | If `true`, the ALB is not exposed via a public IP. | | `alb.stackit.cloud/plan-id` | IngressClass | Optional | Sets the service plan for the ALB. | @@ -82,4 +90,4 @@ metadata: | `alb.stackit.cloud/priority` | Ingress | Optional | Defines the evaluation priority of the Ingress. | | `alb.stackit.cloud/traget-pool-tls-enabled` | IngressClass, Ingress, Service | Optional | Enables TLS bridging using OS trusted CAs. | | `alb.stackit.cloud/traget-pool-tls-custom-ca` | IngressClass, Ingress, Service | Optional | Enables TLS bridging with a custom CA. | -| `alb.stackit.cloud/traget-pool-tls-skip-certificate-validation`| IngressClass, Ingress, Service | Optional | Enables TLS bridging but skips certificate validation. | \ No newline at end of file +| `alb.stackit.cloud/traget-pool-tls-skip-certificate-validation`| IngressClass, Ingress, Service | Optional | Enables TLS bridging but skips certificate validation. |