From bff8f927ae774ca7e886deecf6630e8ec84424a2 Mon Sep 17 00:00:00 2001 From: Manoj Surudwad Date: Mon, 9 Dec 2024 17:05:26 +0530 Subject: [PATCH] fix: validates NUTANIX_ENDPOINT does not fall in the Load Balancer IP Range --- api/v1alpha1/nutanix_clusterconfig_types.go | 2 +- pkg/helpers/helpers.go | 43 ++++++ pkg/helpers/helpers_test.go | 107 +++++++++++++++ pkg/webhook/cluster/nutanix_validator.go | 127 ++++++++++++++++++ pkg/webhook/cluster/nutanix_validator_test.go | 114 ++++++++++++++++ .../cluster/{vaiidator.go => validator.go} | 1 + 6 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 pkg/helpers/helpers.go create mode 100644 pkg/helpers/helpers_test.go create mode 100644 pkg/webhook/cluster/nutanix_validator.go create mode 100644 pkg/webhook/cluster/nutanix_validator_test.go rename pkg/webhook/cluster/{vaiidator.go => validator.go} (89%) diff --git a/api/v1alpha1/nutanix_clusterconfig_types.go b/api/v1alpha1/nutanix_clusterconfig_types.go index 3240eac52..66190a556 100644 --- a/api/v1alpha1/nutanix_clusterconfig_types.go +++ b/api/v1alpha1/nutanix_clusterconfig_types.go @@ -57,7 +57,7 @@ type NutanixPrismCentralEndpointCredentials struct { //nolint:gocritic // No need for named return values func (s NutanixPrismCentralEndpointSpec) ParseURL() (string, uint16, error) { var prismCentralURL *url.URL - prismCentralURL, err := url.Parse(s.URL) + prismCentralURL, err := url.ParseRequestURI(s.URL) if err != nil { return "", 0, fmt.Errorf("error parsing Prism Central URL: %w", err) } diff --git a/pkg/helpers/helpers.go b/pkg/helpers/helpers.go new file mode 100644 index 000000000..3eb948a0f --- /dev/null +++ b/pkg/helpers/helpers.go @@ -0,0 +1,43 @@ +// Copyright 2024 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +package helpers + +import ( + "fmt" + "math/big" + "net" +) + +// IsIPInRange checks if the target IP falls within the start and end IP range (inclusive). +func IsIPInRange(startIP, endIP, targetIP string) (bool, error) { + // Parse the IPs + start := net.ParseIP(startIP) + end := net.ParseIP(endIP) + target := net.ParseIP(targetIP) + + // Ensure all IPs are valid + if start == nil { + return false, fmt.Errorf("invalid start IP: %q", startIP) + } + if end == nil { + return false, fmt.Errorf("invalid end IP: %q", endIP) + } + if target == nil { + return false, fmt.Errorf("invalid target IP: %q", targetIP) + } + + // Convert IPs to big integers + startInt := ipToBigInt(start) + endInt := ipToBigInt(end) + targetInt := ipToBigInt(target) + + // Check if target IP is within the range + return targetInt.Cmp(startInt) >= 0 && targetInt.Cmp(endInt) <= 0, nil +} + +// ipToBigInt converts a net.IP to a big.Int for comparison. +func ipToBigInt(ip net.IP) *big.Int { + // Normalize to 16-byte representation for both IPv4 and IPv6 + ip = ip.To16() + return big.NewInt(0).SetBytes(ip) +} diff --git a/pkg/helpers/helpers_test.go b/pkg/helpers/helpers_test.go new file mode 100644 index 000000000..9095e89c2 --- /dev/null +++ b/pkg/helpers/helpers_test.go @@ -0,0 +1,107 @@ +// Copyright 2024 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package helpers + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsIPInRange(t *testing.T) { + tests := []struct { + name string + startIP string + endIP string + targetIP string + expectedInRange bool + expectedErr error + }{ + { + name: "Valid range - target within range", + startIP: "192.168.1.1", + endIP: "192.168.1.10", + targetIP: "192.168.1.5", + expectedInRange: true, + expectedErr: nil, + }, + { + name: "Valid range - target same as start IP", + startIP: "192.168.1.1", + endIP: "192.168.1.10", + targetIP: "192.168.1.1", + expectedInRange: true, + expectedErr: nil, + }, + { + name: "Valid range - target same as end IP", + startIP: "192.168.1.1", + endIP: "192.168.1.10", + targetIP: "192.168.1.10", + expectedInRange: true, + expectedErr: nil, + }, + { + name: "Valid range - target outside range", + startIP: "192.168.1.1", + endIP: "192.168.1.10", + targetIP: "192.168.1.15", + expectedInRange: false, + expectedErr: nil, + }, + { + name: "Invalid start IP", + startIP: "invalid-ip", + endIP: "192.168.1.10", + targetIP: "192.168.1.5", + expectedInRange: false, + expectedErr: fmt.Errorf("invalid start IP: %q", "invalid-ip"), + }, + { + name: "Invalid end IP", + startIP: "192.168.1.1", + endIP: "invalid-ip", + targetIP: "192.168.1.5", + expectedInRange: false, + expectedErr: fmt.Errorf("invalid end IP: %q", "invalid-ip"), + }, + { + name: "Invalid target IP", + startIP: "192.168.1.1", + endIP: "192.168.1.10", + targetIP: "invalid-ip", + expectedInRange: false, + expectedErr: fmt.Errorf("invalid target IP: %q", "invalid-ip"), + }, + { + name: "IPv6 range - target within range", + startIP: "2001:db8::1", + endIP: "2001:db8::10", + targetIP: "2001:db8::5", + expectedInRange: true, + expectedErr: nil, + }, + { + name: "IPv6 range - target outside range", + startIP: "2001:db8::1", + endIP: "2001:db8::10", + targetIP: "2001:db8::11", + expectedInRange: false, + expectedErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := IsIPInRange(tt.startIP, tt.endIP, tt.targetIP) + assert.Equal(t, tt.expectedInRange, got) + if tt.expectedErr != nil { + assert.EqualError(t, err, tt.expectedErr.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/webhook/cluster/nutanix_validator.go b/pkg/webhook/cluster/nutanix_validator.go new file mode 100644 index 000000000..f77aa742c --- /dev/null +++ b/pkg/webhook/cluster/nutanix_validator.go @@ -0,0 +1,127 @@ +// Copyright 2024 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cluster + +import ( + "context" + "fmt" + "net" + "net/http" + + v1 "k8s.io/api/admission/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/variables" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/utils" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/helpers" +) + +type nutanixValidator struct { + client ctrlclient.Client + decoder admission.Decoder +} + +func NewNutanixValidator( + client ctrlclient.Client, decoder admission.Decoder, +) *nutanixValidator { + return &nutanixValidator{ + client: client, + decoder: decoder, + } +} + +func (a *nutanixValidator) Validator() admission.HandlerFunc { + return a.validate +} + +func (a *nutanixValidator) validate( + ctx context.Context, + req admission.Request, +) admission.Response { + if req.Operation == v1.Delete { + return admission.Allowed("") + } + + cluster := &clusterv1.Cluster{} + err := a.decoder.Decode(req, cluster) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if cluster.Spec.Topology == nil { + return admission.Allowed("") + } + + if utils.GetProvider(cluster) != "nutanix" { + return admission.Allowed("") + } + + clusterConfig, err := variables.UnmarshalClusterConfigVariable(cluster.Spec.Topology.Variables) + if err != nil { + return admission.Denied( + fmt.Errorf("failed to unmarshal cluster topology variable %q: %w", + v1alpha1.ClusterConfigVariableName, + err).Error(), + ) + } + + if clusterConfig.Nutanix != nil && + clusterConfig.Addons != nil { + // Check if Prism Central IP is in MetalLB Load Balancer IP range. + if err := checkIfPrismCentralIPInLoadBalancerIPRange( + clusterConfig.Nutanix.PrismCentralEndpoint, + clusterConfig.Addons.ServiceLoadBalancer, + ); err != nil { + return admission.Denied(err.Error()) + } + } + + return admission.Allowed("") +} + +// checkIfPrismCentralIPInLoadBalancerIPRange checks if the Prism Central IP is in the MetalLB Load Balancer IP range. +// Errors out if Prism Central IP is in the Load Balancer IP range. +func checkIfPrismCentralIPInLoadBalancerIPRange( + pcEndpoint v1alpha1.NutanixPrismCentralEndpointSpec, + serviceLoadBalancerConfiguration *v1alpha1.ServiceLoadBalancer, +) error { + if serviceLoadBalancerConfiguration == nil || + serviceLoadBalancerConfiguration.Provider != v1alpha1.ServiceLoadBalancerProviderMetalLB || + serviceLoadBalancerConfiguration.Configuration == nil { + return nil + } + + pcHostname, _, err := pcEndpoint.ParseURL() + if err != nil { + return err + } + + pcIP := net.ParseIP(pcHostname) + // PC URL can contain IP/FQDN, so compare only if PC is an IP address. + if pcIP == nil { + return nil + } + + for _, pool := range serviceLoadBalancerConfiguration.Configuration.AddressRanges { + isIPInRange, err := helpers.IsIPInRange(pool.Start, pool.End, pcIP.String()) + if err != nil { + return fmt.Errorf( + "error while checking if Prism Central IP %q is part of MetalLB address range %q-%q: %w", + pcIP, + pool.Start, + pool.End, + err, + ) + } + if isIPInRange { + return fmt.Errorf("prism central IP %q must not be part of MetalLB address range %q-%q", + pcIP, pool.Start, pool.End) + } + } + + return nil +} diff --git a/pkg/webhook/cluster/nutanix_validator_test.go b/pkg/webhook/cluster/nutanix_validator_test.go new file mode 100644 index 000000000..3a4923d3a --- /dev/null +++ b/pkg/webhook/cluster/nutanix_validator_test.go @@ -0,0 +1,114 @@ +// Copyright 2024 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cluster + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" +) + +func TestCheckIfPrismCentralIPInLoadBalancerIPRange(t *testing.T) { + tests := []struct { + name string + pcEndpoint v1alpha1.NutanixPrismCentralEndpointSpec + serviceLoadBalancerConfiguration *v1alpha1.ServiceLoadBalancer + expectedErr error + }{ + { + name: "PC IP not in range", + pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{ + URL: "https://192.168.1.1:9440", + }, + serviceLoadBalancerConfiguration: &v1alpha1.ServiceLoadBalancer{ + Provider: v1alpha1.ServiceLoadBalancerProviderMetalLB, + Configuration: &v1alpha1.ServiceLoadBalancerConfiguration{ + AddressRanges: []v1alpha1.AddressRange{ + {Start: "192.168.1.10", End: "192.168.1.20"}, + }, + }, + }, + expectedErr: nil, + }, + { + name: "PC IP in range", + pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{ + URL: "https://192.168.1.15:9440", + }, + serviceLoadBalancerConfiguration: &v1alpha1.ServiceLoadBalancer{ + Provider: v1alpha1.ServiceLoadBalancerProviderMetalLB, + Configuration: &v1alpha1.ServiceLoadBalancerConfiguration{ + AddressRanges: []v1alpha1.AddressRange{ + {Start: "192.168.1.10", End: "192.168.1.20"}, + }, + }, + }, + expectedErr: fmt.Errorf( + "prism central IP %q must not be part of MetalLB address range %q-%q", + "192.168.1.15", + "192.168.1.10", + "192.168.1.20", + ), + }, + { + name: "Invalid Prism Central URL", + pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{ + URL: "invalid-url", + }, + serviceLoadBalancerConfiguration: &v1alpha1.ServiceLoadBalancer{ + Provider: v1alpha1.ServiceLoadBalancerProviderMetalLB, + Configuration: &v1alpha1.ServiceLoadBalancerConfiguration{ + AddressRanges: []v1alpha1.AddressRange{ + {Start: "192.168.1.10", End: "192.168.1.20"}, + }, + }, + }, + expectedErr: fmt.Errorf( + "error parsing Prism Central URL: parse %q: invalid URI for request", + "invalid-url", + ), + }, + { + name: "Service Load Balancer Configuration is nil", + pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{ + URL: "https://192.168.1.1:9440", + }, + serviceLoadBalancerConfiguration: nil, + expectedErr: nil, + }, + { + name: "Provider is not MetalLB", + pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{ + URL: "https://192.168.1.1:9440", + }, + serviceLoadBalancerConfiguration: &v1alpha1.ServiceLoadBalancer{ + Provider: "other-provider", + Configuration: &v1alpha1.ServiceLoadBalancerConfiguration{ + AddressRanges: []v1alpha1.AddressRange{ + {Start: "192.168.1.10", End: "192.168.1.20"}, + }, + }, + }, + expectedErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkIfPrismCentralIPInLoadBalancerIPRange( + tt.pcEndpoint, + tt.serviceLoadBalancerConfiguration, + ) + + if tt.expectedErr != nil { + assert.Equal(t, tt.expectedErr.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/webhook/cluster/vaiidator.go b/pkg/webhook/cluster/validator.go similarity index 89% rename from pkg/webhook/cluster/vaiidator.go rename to pkg/webhook/cluster/validator.go index 3eae0a7dc..adbddae27 100644 --- a/pkg/webhook/cluster/vaiidator.go +++ b/pkg/webhook/cluster/validator.go @@ -11,5 +11,6 @@ import ( func NewValidator(client ctrlclient.Client, decoder admission.Decoder) admission.Handler { return admission.MultiValidatingHandler( NewClusterUUIDLabeler(client, decoder).Validator(), + NewNutanixValidator(client, decoder).Validator(), ) }