Skip to content

Commit 66c541d

Browse files
authored
Merge pull request #4174 from shuqz/main
[feat gw-api] add hostname handling logic
2 parents 7befe4f + 69e2364 commit 66c541d

File tree

8 files changed

+560
-4
lines changed

8 files changed

+560
-4
lines changed

pkg/gateway/model/model_build_listener.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ func (l listenerBuilderImpl) buildL4ListenerSpec(ctx context.Context, stack core
165165
}
166166

167167
func (l listenerBuilderImpl) buildListenerRules(stack core.Stack, ls *elbv2model.Listener, lb *elbv2model.LoadBalancer, securityGroups securityGroupOutput, gw *gwv1.Gateway, port int32, lbCfg elbv2gw.LoadBalancerConfiguration, routes map[int32][]routeutils.RouteDescriptor) error {
168+
169+
// add hostname handling (sort by precedence order)
170+
sortRoutesByHostnamePrecedence(routes[port])
171+
168172
// TODO for L7 Gateway Implementation
169173
// This is throw away code
170174
// This is temporary implementation for supporting basic multiple HTTPRoute for simple backend refs. We will create default forward action for all the backend refs for all HTTPRoutes for this listener
@@ -185,6 +189,21 @@ func (l listenerBuilderImpl) buildListenerRules(stack core.Stack, ls *elbv2model
185189
},
186190
},
187191
}
192+
193+
// add host header condition
194+
if hostnames := descriptor.GetHostnames(); len(hostnames) > 0 {
195+
hostnamesStringList := make([]string, len(descriptor.GetHostnames()))
196+
for i, j := range descriptor.GetHostnames() {
197+
hostnamesStringList[i] = string(j)
198+
}
199+
conditions = append(conditions, elbv2model.RuleCondition{
200+
Field: elbv2model.RuleConditionFieldHostHeader,
201+
HostHeaderConfig: &elbv2model.HostHeaderConditionConfig{
202+
Values: hostnamesStringList,
203+
},
204+
})
205+
}
206+
188207
actions := buildL4ListenerDefaultActions(targetGroup)
189208
tags, tagsErr := l.tagHelper.getGatewayTags(lbCfg)
190209
if tagsErr != nil {

pkg/gateway/model/utilities.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package model
22

33
import (
4+
"sigs.k8s.io/aws-load-balancer-controller/pkg/gateway/routeutils"
45
elbv2model "sigs.k8s.io/aws-load-balancer-controller/pkg/model/elbv2"
6+
"sort"
57
"strings"
68
)
79

@@ -18,3 +20,25 @@ func isIPv6Supported(ipAddressType elbv2model.IPAddressType) bool {
1820
func isIPv6CIDR(cidr string) bool {
1921
return strings.Contains(cidr, ":")
2022
}
23+
24+
func sortRoutesByHostnamePrecedence(routes []routeutils.RouteDescriptor) {
25+
sort.SliceStable(routes, func(i, j int) bool {
26+
hostnameOne := routes[i].GetHostnames()
27+
hostnameTwo := routes[j].GetHostnames()
28+
29+
if len(hostnameOne) == 0 && len(hostnameTwo) == 0 {
30+
return false
31+
}
32+
if len(hostnameOne) == 0 {
33+
return false
34+
}
35+
if len(hostnameTwo) == 0 {
36+
return true
37+
}
38+
precedence := routeutils.GetHostnamePrecedenceOrder(string(hostnameOne[0]), string(hostnameTwo[0]))
39+
if precedence != 0 {
40+
return precedence < 0 // -1 means higher precedence
41+
}
42+
return false
43+
})
44+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package model
2+
3+
import (
4+
"github.com/stretchr/testify/assert"
5+
"sigs.k8s.io/aws-load-balancer-controller/pkg/gateway/routeutils"
6+
elbv2model "sigs.k8s.io/aws-load-balancer-controller/pkg/model/elbv2"
7+
"testing"
8+
)
9+
10+
// Test IsIPv6Supported
11+
func Test_IsIPv6Supported(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
ipAddressType elbv2model.IPAddressType
15+
expected bool
16+
}{
17+
{
18+
name: "DualStack should support IPv6",
19+
ipAddressType: elbv2model.IPAddressTypeDualStack,
20+
expected: true,
21+
},
22+
{
23+
name: "DualStackWithoutPublicIPV4 should support IPv6",
24+
ipAddressType: elbv2model.IPAddressTypeDualStackWithoutPublicIPV4,
25+
expected: true,
26+
},
27+
{
28+
name: "IPv4 should not support IPv6",
29+
ipAddressType: elbv2model.IPAddressTypeIPV4,
30+
expected: false,
31+
},
32+
{
33+
name: "Empty address type should not support IPv6",
34+
ipAddressType: "",
35+
expected: false,
36+
},
37+
{
38+
name: "Unknown address type should not support IPv6",
39+
ipAddressType: "unknown",
40+
expected: false,
41+
},
42+
}
43+
44+
for _, tt := range tests {
45+
t.Run(tt.name, func(t *testing.T) {
46+
got := isIPv6Supported(tt.ipAddressType)
47+
assert.Equal(t, tt.expected, got, "isIPv6Supported() = %v, want %v", got, tt.expected)
48+
})
49+
}
50+
}
51+
52+
// Test SortRoutesByHostnamePrecedence
53+
func Test_SortRoutesByHostnamePrecedence(t *testing.T) {
54+
tests := []struct {
55+
name string
56+
input []routeutils.RouteDescriptor
57+
expected []routeutils.RouteDescriptor
58+
}{
59+
{
60+
name: "empty routes",
61+
input: []routeutils.RouteDescriptor{},
62+
expected: []routeutils.RouteDescriptor{},
63+
},
64+
{
65+
name: "routes with no hostnames",
66+
input: []routeutils.RouteDescriptor{
67+
&routeutils.MockRoute{Hostnames: []string{}},
68+
&routeutils.MockRoute{Hostnames: []string{}},
69+
},
70+
expected: []routeutils.RouteDescriptor{
71+
&routeutils.MockRoute{Hostnames: []string{}},
72+
&routeutils.MockRoute{Hostnames: []string{}},
73+
},
74+
},
75+
{
76+
name: "mix of empty and non-empty hostnames",
77+
input: []routeutils.RouteDescriptor{
78+
&routeutils.MockRoute{Hostnames: []string{}},
79+
&routeutils.MockRoute{Hostnames: []string{"example.com"}},
80+
&routeutils.MockRoute{Hostnames: []string{"test.com"}},
81+
},
82+
expected: []routeutils.RouteDescriptor{
83+
&routeutils.MockRoute{Hostnames: []string{"example.com"}},
84+
&routeutils.MockRoute{Hostnames: []string{"test.com"}},
85+
&routeutils.MockRoute{Hostnames: []string{}},
86+
},
87+
},
88+
{
89+
name: "with and without wildcard hostnames",
90+
input: []routeutils.RouteDescriptor{
91+
&routeutils.MockRoute{Hostnames: []string{"*.example.com"}},
92+
&routeutils.MockRoute{Hostnames: []string{"test.example.com"}},
93+
},
94+
expected: []routeutils.RouteDescriptor{
95+
&routeutils.MockRoute{Hostnames: []string{"test.example.com"}},
96+
&routeutils.MockRoute{Hostnames: []string{"*.example.com"}},
97+
},
98+
},
99+
{
100+
name: "complex mixed hostnames",
101+
input: []routeutils.RouteDescriptor{
102+
&routeutils.MockRoute{Hostnames: []string{"*.example.com"}},
103+
&routeutils.MockRoute{Hostnames: []string{}},
104+
&routeutils.MockRoute{Hostnames: []string{"test.example.com"}},
105+
&routeutils.MockRoute{Hostnames: []string{"another.example.com"}},
106+
},
107+
expected: []routeutils.RouteDescriptor{
108+
&routeutils.MockRoute{Hostnames: []string{"another.example.com"}},
109+
&routeutils.MockRoute{Hostnames: []string{"test.example.com"}},
110+
&routeutils.MockRoute{Hostnames: []string{"*.example.com"}},
111+
&routeutils.MockRoute{Hostnames: []string{}},
112+
},
113+
},
114+
}
115+
116+
for _, tt := range tests {
117+
t.Run(tt.name, func(t *testing.T) {
118+
// Create a copy of input to avoid modifying the test case data
119+
actual := make([]routeutils.RouteDescriptor, len(tt.input))
120+
copy(actual, tt.input)
121+
122+
// Execute the sort
123+
sortRoutesByHostnamePrecedence(actual)
124+
125+
// Verify the result
126+
assert.Equal(t, tt.expected, actual, "sorted routes should match expected order")
127+
128+
// Verify stability of sort
129+
sortRoutesByHostnamePrecedence(actual)
130+
assert.Equal(t, tt.expected, actual, "second sort should maintain the same order (stable sort)")
131+
})
132+
}
133+
}

pkg/gateway/routeutils/listener_attachment_helper.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,33 @@ func newListenerAttachmentHelper(k8sClient client.Client, logger logr.Logger) li
3232
// listenerAllowsAttachment utility method to determine if a listener will allow a route to connect using
3333
// Gateway API rules to determine compatibility between lister and route.
3434
func (attachmentHelper *listenerAttachmentHelperImpl) listenerAllowsAttachment(ctx context.Context, gw gwv1.Gateway, listener gwv1.Listener, route preLoadRouteDescriptor) (bool, error) {
35+
// check namespace
3536
namespaceOK, err := attachmentHelper.namespaceCheck(ctx, gw, listener, route)
3637
if err != nil {
3738
return false, err
3839
}
3940
if !namespaceOK {
4041
return false, nil
4142
}
42-
return attachmentHelper.kindCheck(listener, route), nil
43+
44+
// check kind
45+
kindOK := attachmentHelper.kindCheck(listener, route)
46+
if !kindOK {
47+
return false, nil
48+
}
49+
50+
// check hostname
51+
if (route.GetRouteKind() == HTTPRouteKind || route.GetRouteKind() == GRPCRouteKind || route.GetRouteKind() == TLSRouteKind) && route.GetHostnames() != nil {
52+
hostnameOK, err := attachmentHelper.hostnameCheck(listener, route)
53+
if err != nil {
54+
return false, err
55+
}
56+
if !hostnameOK {
57+
return false, nil
58+
}
59+
}
60+
61+
return true, nil
4362
}
4463

4564
// namespaceCheck namespace check implements the Gateway API spec for namespace matching between listener
@@ -104,3 +123,35 @@ func (attachmentHelper *listenerAttachmentHelperImpl) kindCheck(listener gwv1.Li
104123
}
105124
return allowedRoutes.Has(route.GetRouteKind())
106125
}
126+
127+
func (attachmentHelper *listenerAttachmentHelperImpl) hostnameCheck(listener gwv1.Listener, route preLoadRouteDescriptor) (bool, error) {
128+
// A route can attach to listener if it does not have hostname or listener does not have hostname
129+
if listener.Hostname == nil || len(route.GetHostnames()) == 0 {
130+
return true, nil
131+
}
132+
133+
// validate listener hostname, return if listener hostname is not valid
134+
isListenerHostnameValid, err := IsHostNameInValidFormat(string(*listener.Hostname))
135+
if err != nil {
136+
attachmentHelper.logger.Error(err, "listener hostname is not valid", "listener", listener.Name, "hostname", *listener.Hostname)
137+
return false, err
138+
}
139+
if !isListenerHostnameValid {
140+
return false, nil
141+
}
142+
143+
for _, hostname := range route.GetHostnames() {
144+
// validate route hostname, skip invalid hostname
145+
isHostnameValid, err := IsHostNameInValidFormat(string(hostname))
146+
if err != nil || !isHostnameValid {
147+
attachmentHelper.logger.V(1).Info("route hostname is not valid, continue...", "route", route.GetRouteNamespacedName(), "hostname", hostname)
148+
continue
149+
}
150+
151+
// check if two hostnames have overlap (compatible)
152+
if isHostnameCompatible(string(hostname), string(*listener.Hostname)) {
153+
return true, nil
154+
}
155+
}
156+
return false, nil
157+
}

pkg/gateway/routeutils/loader.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,9 @@ func NewLoader(k8sClient client.Client, logger logr.Logger) Loader {
7171
// LoadRoutesForGateway loads all relevant data for a single Gateway.
7272
func (l *loaderImpl) LoadRoutesForGateway(ctx context.Context, gw gwv1.Gateway, filter LoadRouteFilter) (map[int32][]RouteDescriptor, error) {
7373
// 1. Load all relevant routes according to the filter
74+
7475
loadedRoutes := make([]preLoadRouteDescriptor, 0)
7576
for route, loader := range l.allRouteLoaders {
76-
7777
applicable := filter.IsApplicable(route)
7878
l.logger.V(1).Info("Processing route", "route", route, "is applicable", applicable)
7979
if applicable {

pkg/gateway/routeutils/mock_route.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type MockRoute struct {
99
Kind RouteKind
1010
Name string
1111
Namespace string
12+
Hostnames []string
1213
}
1314

1415
func (m *MockRoute) GetBackendRefs() []gwv1.BackendRef {
@@ -28,8 +29,11 @@ func (m *MockRoute) GetRouteKind() RouteKind {
2829
}
2930

3031
func (m *MockRoute) GetHostnames() []gwv1.Hostname {
31-
//TODO implement me
32-
panic("implement me")
32+
hostnames := make([]gwv1.Hostname, len(m.Hostnames))
33+
for i, h := range m.Hostnames {
34+
hostnames[i] = gwv1.Hostname(h)
35+
}
36+
return hostnames
3337
}
3438

3539
func (m *MockRoute) GetParentRefs() []gwv1.ParentReference {

pkg/gateway/routeutils/utils.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"fmt"
66
corev1 "k8s.io/api/core/v1"
77
"k8s.io/apimachinery/pkg/types"
8+
"net"
89
"sigs.k8s.io/controller-runtime/pkg/client"
10+
"strings"
911
)
1012

1113
// ListL4Routes retrieves all Layer 4 routes (TCP, UDP, TLS) from the cluster.
@@ -87,3 +89,74 @@ func isServiceReferredByRoute(route preLoadRouteDescriptor, svcID types.Namespac
8789
}
8890
return false
8991
}
92+
93+
// IsHostNameInValidFormat follows RFC1123 requirement except
94+
// 1. no IP allowed
95+
// 2. wildcard is only allowed as leftmost character
96+
// Allowed Characters: Hostname labels must only contain lowercase ASCII letters (a-z), digits (0-9), and hyphens (-).
97+
// Starting with a Digit: RFC 1123 allows labels to begin with a digit, which is a departure from the previous RFC 952 restriction.
98+
// Length: Each label in a hostname can be between 1 and 63 characters long.
99+
// Overall Hostname Length: The entire hostname, including the periods separating labels, cannot exceed 253 characters.
100+
// Case: Hostnames are case-insensitive.
101+
// Underscore: Underscores are not permitted in hostnames.
102+
// Other Symbols: No other symbols, punctuation, or whitespace is allowed in hostnames
103+
// Most of the requirements above is already checked by CRD pattern: Pattern=`^(\*\.)?[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
104+
// Thus this function only checks for 1. if it is IP 2. label length is between 1 and 63
105+
func IsHostNameInValidFormat(hostName string) (bool, error) {
106+
if net.ParseIP(hostName) != nil {
107+
108+
return false, fmt.Errorf("hostname can not be IP address")
109+
}
110+
labels := strings.Split(hostName, ".")
111+
if strings.HasPrefix(hostName, "*.") {
112+
labels = labels[1:]
113+
}
114+
for _, label := range labels {
115+
if len(label) < 1 || len(label) > 63 {
116+
return false, fmt.Errorf("invalid hostname label length, length must between 1 and 63")
117+
}
118+
}
119+
return true, nil
120+
}
121+
122+
// isHostnameCompatible checks if given two hostnames are compatible with each other
123+
// this function is used to check if listener hostname and Route hostname match
124+
func isHostnameCompatible(hostnameOne, hostnameTwo string) bool {
125+
// exact match
126+
if hostnameOne == hostnameTwo {
127+
return true
128+
}
129+
130+
// suffix match - hostnameOne is a wildcard
131+
if strings.HasPrefix(hostnameOne, "*.") && strings.HasSuffix(hostnameTwo, hostnameOne[1:]) {
132+
return true
133+
}
134+
// suffix match - hostnameTwo is a wildcard
135+
if strings.HasPrefix(hostnameTwo, "*.") && strings.HasSuffix(hostnameOne, hostnameTwo[1:]) {
136+
return true
137+
}
138+
return false
139+
}
140+
141+
// GetHostnamePrecedenceOrder Hostname precedence ordering rule:
142+
// 1. non-wildcard has higher precedence than wildcard
143+
// 2. hostname with longer characters have higher precedence than those with shorter ones
144+
// -1 means hostnameOne has higher precedence, 1 means hostnameTwo has higher precedence, 0 means equal
145+
func GetHostnamePrecedenceOrder(hostnameOne, hostnameTwo string) int {
146+
isHostnameOneWildcard := strings.HasPrefix(hostnameOne, "*.")
147+
isHostnameTwoWildcard := strings.HasPrefix(hostnameTwo, "*.")
148+
149+
if !isHostnameOneWildcard && isHostnameTwoWildcard {
150+
return -1
151+
} else if isHostnameOneWildcard && !isHostnameTwoWildcard {
152+
return 1
153+
} else {
154+
if len(hostnameOne) > len(hostnameTwo) {
155+
return -1
156+
} else if len(hostnameOne) < len(hostnameTwo) {
157+
return 1
158+
} else {
159+
return 0
160+
}
161+
}
162+
}

0 commit comments

Comments
 (0)