Skip to content

Commit b519340

Browse files
committed
fix: validates NUTANIX_ENDPOINT is outside the Load Balancer IP Range
1 parent 8c18f88 commit b519340

File tree

6 files changed

+385
-1
lines changed

6 files changed

+385
-1
lines changed

api/v1alpha1/nutanix_clusterconfig_types.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ type NutanixPrismCentralEndpointCredentials struct {
5757
//nolint:gocritic // No need for named return values
5858
func (s NutanixPrismCentralEndpointSpec) ParseURL() (string, uint16, error) {
5959
var prismCentralURL *url.URL
60-
prismCentralURL, err := url.Parse(s.URL)
60+
prismCentralURL, err := url.ParseRequestURI(s.URL)
6161
if err != nil {
6262
return "", 0, fmt.Errorf("error parsing Prism Central URL: %w", err)
6363
}

pkg/helpers/helpers.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2024 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package helpers
4+
5+
import (
6+
"fmt"
7+
"net/netip"
8+
)
9+
10+
// IsIPInRange checks if the target IP falls within the start and end IP range (inclusive).
11+
func IsIPInRange(startIP, endIP, targetIP string) (bool, error) {
12+
start, err := netip.ParseAddr(startIP)
13+
if err != nil {
14+
return false, fmt.Errorf("invalid start IP: %w", err)
15+
}
16+
end, err := netip.ParseAddr(endIP)
17+
if err != nil {
18+
return false, fmt.Errorf("invalid end IP: %w", err)
19+
}
20+
target, err := netip.ParseAddr(targetIP)
21+
if err != nil {
22+
return false, fmt.Errorf("invalid target IP: %w", err)
23+
}
24+
25+
return start.Compare(target) <= 0 && end.Compare(target) >= 0, nil
26+
}

pkg/helpers/helpers_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright 2024 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package helpers
5+
6+
import (
7+
"fmt"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestIsIPInRange(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
startIP string
17+
endIP string
18+
targetIP string
19+
expectedInRange bool
20+
expectedErr error
21+
}{
22+
{
23+
name: "Valid range - target within range",
24+
startIP: "192.168.1.1",
25+
endIP: "192.168.1.10",
26+
targetIP: "192.168.1.5",
27+
expectedInRange: true,
28+
expectedErr: nil,
29+
},
30+
{
31+
name: "Valid range - target same as start IP",
32+
startIP: "192.168.1.1",
33+
endIP: "192.168.1.10",
34+
targetIP: "192.168.1.1",
35+
expectedInRange: true,
36+
expectedErr: nil,
37+
},
38+
{
39+
name: "Valid range - target same as end IP",
40+
startIP: "192.168.1.1",
41+
endIP: "192.168.1.10",
42+
targetIP: "192.168.1.10",
43+
expectedInRange: true,
44+
expectedErr: nil,
45+
},
46+
{
47+
name: "Valid range - target outside range",
48+
startIP: "192.168.1.1",
49+
endIP: "192.168.1.10",
50+
targetIP: "192.168.1.15",
51+
expectedInRange: false,
52+
expectedErr: nil,
53+
},
54+
{
55+
name: "Invalid start IP",
56+
startIP: "invalid-ip",
57+
endIP: "192.168.1.10",
58+
targetIP: "192.168.1.5",
59+
expectedInRange: false,
60+
expectedErr: fmt.Errorf(
61+
"invalid start IP: ParseAddr(%q): unable to parse IP",
62+
"invalid-ip",
63+
),
64+
},
65+
{
66+
name: "Invalid end IP",
67+
startIP: "192.168.1.1",
68+
endIP: "invalid-ip",
69+
targetIP: "192.168.1.5",
70+
expectedInRange: false,
71+
expectedErr: fmt.Errorf(
72+
"invalid end IP: ParseAddr(%q): unable to parse IP",
73+
"invalid-ip",
74+
),
75+
},
76+
{
77+
name: "Invalid target IP",
78+
startIP: "192.168.1.1",
79+
endIP: "192.168.1.10",
80+
targetIP: "invalid-ip",
81+
expectedInRange: false,
82+
expectedErr: fmt.Errorf(
83+
"invalid target IP: ParseAddr(%q): unable to parse IP",
84+
"invalid-ip",
85+
),
86+
},
87+
{
88+
name: "IPv6 range - target within range",
89+
startIP: "2001:db8::1",
90+
endIP: "2001:db8::10",
91+
targetIP: "2001:db8::5",
92+
expectedInRange: true,
93+
expectedErr: nil,
94+
},
95+
{
96+
name: "IPv6 range - target outside range",
97+
startIP: "2001:db8::1",
98+
endIP: "2001:db8::10",
99+
targetIP: "2001:db8::11",
100+
expectedInRange: false,
101+
expectedErr: nil,
102+
},
103+
}
104+
105+
for _, tt := range tests {
106+
t.Run(tt.name, func(t *testing.T) {
107+
got, err := IsIPInRange(tt.startIP, tt.endIP, tt.targetIP)
108+
assert.Equal(t, tt.expectedInRange, got)
109+
if tt.expectedErr != nil {
110+
assert.EqualError(t, err, tt.expectedErr.Error())
111+
} else {
112+
assert.NoError(t, err)
113+
}
114+
})
115+
}
116+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright 2024 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cluster
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"net"
10+
"net/http"
11+
12+
v1 "k8s.io/api/admission/v1"
13+
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
14+
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
15+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
16+
17+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1"
18+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/variables"
19+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/utils"
20+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/helpers"
21+
)
22+
23+
type nutanixValidator struct {
24+
client ctrlclient.Client
25+
decoder admission.Decoder
26+
}
27+
28+
func NewNutanixValidator(
29+
client ctrlclient.Client, decoder admission.Decoder,
30+
) *nutanixValidator {
31+
return &nutanixValidator{
32+
client: client,
33+
decoder: decoder,
34+
}
35+
}
36+
37+
func (a *nutanixValidator) Validator() admission.HandlerFunc {
38+
return a.validate
39+
}
40+
41+
func (a *nutanixValidator) validate(
42+
ctx context.Context,
43+
req admission.Request,
44+
) admission.Response {
45+
if req.Operation == v1.Delete {
46+
return admission.Allowed("")
47+
}
48+
49+
cluster := &clusterv1.Cluster{}
50+
err := a.decoder.Decode(req, cluster)
51+
if err != nil {
52+
return admission.Errored(http.StatusBadRequest, err)
53+
}
54+
55+
if cluster.Spec.Topology == nil {
56+
return admission.Allowed("")
57+
}
58+
59+
if utils.GetProvider(cluster) != "nutanix" {
60+
return admission.Allowed("")
61+
}
62+
63+
clusterConfig, err := variables.UnmarshalClusterConfigVariable(cluster.Spec.Topology.Variables)
64+
if err != nil {
65+
return admission.Denied(
66+
fmt.Errorf("failed to unmarshal cluster topology variable %q: %w",
67+
v1alpha1.ClusterConfigVariableName,
68+
err).Error(),
69+
)
70+
}
71+
72+
if clusterConfig.Nutanix != nil &&
73+
clusterConfig.Addons != nil {
74+
// Check if Prism Central IP is in MetalLB Load Balancer IP range.
75+
if err := checkIfPrismCentralIPInLoadBalancerIPRange(
76+
clusterConfig.Nutanix.PrismCentralEndpoint,
77+
clusterConfig.Addons.ServiceLoadBalancer,
78+
); err != nil {
79+
return admission.Denied(err.Error())
80+
}
81+
}
82+
83+
return admission.Allowed("")
84+
}
85+
86+
// checkIfPrismCentralIPInLoadBalancerIPRange checks if the Prism Central IP is in the MetalLB Load Balancer IP range.
87+
// Errors out if Prism Central IP is in the Load Balancer IP range.
88+
func checkIfPrismCentralIPInLoadBalancerIPRange(
89+
pcEndpoint v1alpha1.NutanixPrismCentralEndpointSpec,
90+
serviceLoadBalancerConfiguration *v1alpha1.ServiceLoadBalancer,
91+
) error {
92+
if serviceLoadBalancerConfiguration == nil ||
93+
serviceLoadBalancerConfiguration.Provider != v1alpha1.ServiceLoadBalancerProviderMetalLB ||
94+
serviceLoadBalancerConfiguration.Configuration == nil {
95+
return nil
96+
}
97+
98+
pcHostname, _, err := pcEndpoint.ParseURL()
99+
if err != nil {
100+
return err
101+
}
102+
103+
pcIP := net.ParseIP(pcHostname)
104+
// PC URL can contain IP/FQDN, so compare only if PC is an IP address.
105+
if pcIP == nil {
106+
return nil
107+
}
108+
109+
for _, pool := range serviceLoadBalancerConfiguration.Configuration.AddressRanges {
110+
isIPInRange, err := helpers.IsIPInRange(pool.Start, pool.End, pcIP.String())
111+
if err != nil {
112+
return fmt.Errorf(
113+
"error while checking if Prism Central IP %q is part of MetalLB address range %q-%q: %w",
114+
pcIP,
115+
pool.Start,
116+
pool.End,
117+
err,
118+
)
119+
}
120+
if isIPInRange {
121+
return fmt.Errorf("prism central IP %q must not be part of MetalLB address range %q-%q",
122+
pcIP, pool.Start, pool.End)
123+
}
124+
}
125+
126+
return nil
127+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright 2024 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cluster
5+
6+
import (
7+
"fmt"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
12+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1"
13+
)
14+
15+
func TestCheckIfPrismCentralIPInLoadBalancerIPRange(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
pcEndpoint v1alpha1.NutanixPrismCentralEndpointSpec
19+
serviceLoadBalancerConfiguration *v1alpha1.ServiceLoadBalancer
20+
expectedErr error
21+
}{
22+
{
23+
name: "PC IP not in range",
24+
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
25+
URL: "https://192.168.1.1:9440",
26+
},
27+
serviceLoadBalancerConfiguration: &v1alpha1.ServiceLoadBalancer{
28+
Provider: v1alpha1.ServiceLoadBalancerProviderMetalLB,
29+
Configuration: &v1alpha1.ServiceLoadBalancerConfiguration{
30+
AddressRanges: []v1alpha1.AddressRange{
31+
{Start: "192.168.1.10", End: "192.168.1.20"},
32+
},
33+
},
34+
},
35+
expectedErr: nil,
36+
},
37+
{
38+
name: "PC IP in range",
39+
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
40+
URL: "https://192.168.1.15:9440",
41+
},
42+
serviceLoadBalancerConfiguration: &v1alpha1.ServiceLoadBalancer{
43+
Provider: v1alpha1.ServiceLoadBalancerProviderMetalLB,
44+
Configuration: &v1alpha1.ServiceLoadBalancerConfiguration{
45+
AddressRanges: []v1alpha1.AddressRange{
46+
{Start: "192.168.1.10", End: "192.168.1.20"},
47+
},
48+
},
49+
},
50+
expectedErr: fmt.Errorf(
51+
"prism central IP %q must not be part of MetalLB address range %q-%q",
52+
"192.168.1.15",
53+
"192.168.1.10",
54+
"192.168.1.20",
55+
),
56+
},
57+
{
58+
name: "Invalid Prism Central URL",
59+
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
60+
URL: "invalid-url",
61+
},
62+
serviceLoadBalancerConfiguration: &v1alpha1.ServiceLoadBalancer{
63+
Provider: v1alpha1.ServiceLoadBalancerProviderMetalLB,
64+
Configuration: &v1alpha1.ServiceLoadBalancerConfiguration{
65+
AddressRanges: []v1alpha1.AddressRange{
66+
{Start: "192.168.1.10", End: "192.168.1.20"},
67+
},
68+
},
69+
},
70+
expectedErr: fmt.Errorf(
71+
"error parsing Prism Central URL: parse %q: invalid URI for request",
72+
"invalid-url",
73+
),
74+
},
75+
{
76+
name: "Service Load Balancer Configuration is nil",
77+
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
78+
URL: "https://192.168.1.1:9440",
79+
},
80+
serviceLoadBalancerConfiguration: nil,
81+
expectedErr: nil,
82+
},
83+
{
84+
name: "Provider is not MetalLB",
85+
pcEndpoint: v1alpha1.NutanixPrismCentralEndpointSpec{
86+
URL: "https://192.168.1.1:9440",
87+
},
88+
serviceLoadBalancerConfiguration: &v1alpha1.ServiceLoadBalancer{
89+
Provider: "other-provider",
90+
Configuration: &v1alpha1.ServiceLoadBalancerConfiguration{
91+
AddressRanges: []v1alpha1.AddressRange{
92+
{Start: "192.168.1.10", End: "192.168.1.20"},
93+
},
94+
},
95+
},
96+
expectedErr: nil,
97+
},
98+
}
99+
100+
for _, tt := range tests {
101+
t.Run(tt.name, func(t *testing.T) {
102+
err := checkIfPrismCentralIPInLoadBalancerIPRange(
103+
tt.pcEndpoint,
104+
tt.serviceLoadBalancerConfiguration,
105+
)
106+
107+
if tt.expectedErr != nil {
108+
assert.Equal(t, tt.expectedErr.Error(), err.Error())
109+
} else {
110+
assert.NoError(t, err)
111+
}
112+
})
113+
}
114+
}

0 commit comments

Comments
 (0)