From 70a02158e54342ddcabd3a849c58238f6e07980a Mon Sep 17 00:00:00 2001 From: Joel Berger Date: Thu, 29 Apr 2021 10:18:04 -0500 Subject: [PATCH] Add wildcard hostname rule to kubernetes gateway --- .../fixtures/with_two_hosts_one_wildcard.yml | 43 +++++ .../fixtures/with_two_hosts_wildcard.yml | 43 +++++ pkg/provider/kubernetes/gateway/kubernetes.go | 59 +++++- .../kubernetes/gateway/kubernetes_test.go | 174 +++++++++++++++++- 4 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 pkg/provider/kubernetes/gateway/fixtures/with_two_hosts_one_wildcard.yml create mode 100644 pkg/provider/kubernetes/gateway/fixtures/with_two_hosts_wildcard.yml diff --git a/pkg/provider/kubernetes/gateway/fixtures/with_two_hosts_one_wildcard.yml b/pkg/provider/kubernetes/gateway/fixtures/with_two_hosts_one_wildcard.yml new file mode 100644 index 000000000..e6718f6f4 --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/with_two_hosts_one_wildcard.yml @@ -0,0 +1,43 @@ +--- +kind: GatewayClass +apiVersion: networking.x-k8s.io/v1alpha1 +metadata: + name: my-gateway-class +spec: + controller: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: networking.x-k8s.io/v1alpha1 +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: my-gateway-class + listeners: # Use GatewayClass defaults for listener definition. + - protocol: HTTP + port: 80 + routes: + kind: HTTPRoute + namespaces: + from: Same + selector: + app: foo + +--- +kind: HTTPRoute +apiVersion: networking.x-k8s.io/v1alpha1 +metadata: + name: http-app-1 + namespace: default + labels: + app: foo +spec: + hostnames: + - "foo.com" + - "*.bar.com" + rules: + - forwardTo: + - serviceName: whoami + port: 80 + weight: 1 diff --git a/pkg/provider/kubernetes/gateway/fixtures/with_two_hosts_wildcard.yml b/pkg/provider/kubernetes/gateway/fixtures/with_two_hosts_wildcard.yml new file mode 100644 index 000000000..d768cf7c2 --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/with_two_hosts_wildcard.yml @@ -0,0 +1,43 @@ +--- +kind: GatewayClass +apiVersion: networking.x-k8s.io/v1alpha1 +metadata: + name: my-gateway-class +spec: + controller: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: networking.x-k8s.io/v1alpha1 +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: my-gateway-class + listeners: # Use GatewayClass defaults for listener definition. + - protocol: HTTP + port: 80 + routes: + kind: HTTPRoute + namespaces: + from: Same + selector: + app: foo + +--- +kind: HTTPRoute +apiVersion: networking.x-k8s.io/v1alpha1 +metadata: + name: http-app-1 + namespace: default + labels: + app: foo +spec: + hostnames: + - "foo.com" + - "*" + rules: + - forwardTo: + - serviceName: whoami + port: 80 + weight: 1 diff --git a/pkg/provider/kubernetes/gateway/kubernetes.go b/pkg/provider/kubernetes/gateway/kubernetes.go index 2efdf9d42..491c0235c 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes.go +++ b/pkg/provider/kubernetes/gateway/kubernetes.go @@ -423,7 +423,17 @@ func (p *Provider) fillGatewayConf(client Client, gateway *v1alpha1.Gateway, con continue } - hostRule := hostRule(httpRoute.Spec) + hostRule, err := hostRule(httpRoute.Spec) + if err != nil { + listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{ + Type: string(v1alpha1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: string(v1alpha1.ListenerReasonDegradedRoutes), + Message: fmt.Sprintf("Skipping HTTPRoute %s: invalid hostname: %v", httpRoute.Name, err), + }) + continue + } for _, routeRule := range httpRoute.Spec.Rules { rule, err := extractRule(routeRule, hostRule) @@ -572,20 +582,49 @@ func (p *Provider) makeGatewayStatus(listenerStatuses []v1alpha1.ListenerStatus) return gatewayStatus, nil } -func hostRule(httpRouteSpec v1alpha1.HTTPRouteSpec) string { - hostRule := "" - for i, hostname := range httpRouteSpec.Hostnames { - if i > 0 && len(hostname) > 0 { - hostRule += "`, `" +func hostRule(httpRouteSpec v1alpha1.HTTPRouteSpec) (string, error) { + var hostNames []string + var hostRegexNames []string + + for _, hostname := range httpRouteSpec.Hostnames { + host := string(hostname) + // When unspecified, "", or *, all hostnames are matched. + // This field can be omitted for protocols that don't require hostname based matching. + // TODO Refactor this when building support for TLS options. + if host == "*" || host == "" { + return "", nil } - hostRule += string(hostname) + + wildcard := strings.Count(host, "*") + if wildcard == 0 { + hostNames = append(hostNames, host) + continue + } + + // https://gateway-api.sigs.k8s.io/spec/#networking.x-k8s.io/v1alpha1.Hostname + if !strings.HasPrefix(host, "*.") || wildcard > 1 { + return "", fmt.Errorf("invalid rule: %q", host) + } + + hostRegexNames = append(hostRegexNames, strings.Replace(host, "*.", "{subdomain:[a-zA-Z0-9-]+}.", 1)) } - if hostRule != "" { - return "Host(`" + hostRule + "`)" + var res string + if len(hostNames) > 0 { + res = "Host(`" + strings.Join(hostNames, "`, `") + "`)" } - return "" + if len(hostRegexNames) == 0 { + return res, nil + } + + hostRegexp := "HostRegexp(`" + strings.Join(hostRegexNames, "`, `") + "`)" + + if len(res) > 0 { + return "(" + res + " || " + hostRegexp + ")", nil + } + + return hostRegexp, nil } func extractRule(routeRule v1alpha1.HTTPRouteRule, hostRule string) (string, error) { diff --git a/pkg/provider/kubernetes/gateway/kubernetes_test.go b/pkg/provider/kubernetes/gateway/kubernetes_test.go index 68ec0f74f..09f4fe100 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes_test.go +++ b/pkg/provider/kubernetes/gateway/kubernetes_test.go @@ -436,6 +436,112 @@ func TestLoadHTTPRoutes(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Simple HTTPRoute, with two hosts one wildcard", + paths: []string{"services.yml", "with_two_hosts_one_wildcard.yml"}, + entryPoints: map[string]Entrypoint{"web": { + Address: ":80", + }}, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-http-app-1-my-gateway-web-2dbd7883f5537db39bca": { + EntryPoints: []string{"web"}, + Service: "default-http-app-1-my-gateway-web-2dbd7883f5537db39bca-wrr", + Rule: "(Host(`foo.com`) || HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.bar.com`)) && PathPrefix(`/`)", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-http-app-1-my-gateway-web-2dbd7883f5537db39bca-wrr": { + Weighted: &dynamic.WeightedRoundRobin{ + Services: []dynamic.WRRService{ + { + Name: "default-whoami-80", + Weight: func(i int) *int { return &i }(1), + }, + }, + }, + }, + "default-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: Bool(true), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Simple HTTPRoute, with one host and a wildcard", + paths: []string{"services.yml", "with_two_hosts_wildcard.yml"}, + entryPoints: map[string]Entrypoint{"web": { + Address: ":80", + }}, + expected: &dynamic.Configuration{ + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-http-app-1-my-gateway-web-a431b128267aabc954fd": { + EntryPoints: []string{"web"}, + Service: "default-http-app-1-my-gateway-web-a431b128267aabc954fd-wrr", + Rule: "PathPrefix(`/`)", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-http-app-1-my-gateway-web-a431b128267aabc954fd-wrr": { + Weighted: &dynamic.WeightedRoundRobin{ + Services: []dynamic.WRRService{ + { + Name: "default-whoami-80", + Weight: func(i int) *int { return &i }(1), + }, + }, + }, + }, + "default-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + PassHostHeader: Bool(true), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "One HTTPRoute with two different rules", paths: []string{"services.yml", "two_rules.yml"}, @@ -845,6 +951,7 @@ func TestHostRule(t *testing.T) { desc string routeSpec v1alpha1.HTTPRouteSpec expectedRule string + expectErr bool }{ { desc: "Empty rule and matches", @@ -879,7 +986,7 @@ func TestHostRule(t *testing.T) { "Bir", }, }, - expectedRule: "Host(`Foo`, `Bir`)", + expectedRule: "", }, { desc: "Multiple empty hosts", @@ -892,14 +999,77 @@ func TestHostRule(t *testing.T) { }, expectedRule: "", }, + { + desc: "Several Host and wildcard", + routeSpec: v1alpha1.HTTPRouteSpec{ + Hostnames: []v1alpha1.Hostname{ + "*.bar.foo", + "bar.foo", + "foo.foo", + }, + }, + expectedRule: "(Host(`bar.foo`, `foo.foo`) || HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.bar.foo`))", + }, + { + desc: "Host with wildcard", + routeSpec: v1alpha1.HTTPRouteSpec{ + Hostnames: []v1alpha1.Hostname{ + "*.bar.foo", + }, + }, + expectedRule: "HostRegexp(`{subdomain:[a-zA-Z0-9-]+}.bar.foo`)", + }, + { + desc: "Alone wildcard", + routeSpec: v1alpha1.HTTPRouteSpec{ + Hostnames: []v1alpha1.Hostname{ + "*", + "*.foo.foo", + }, + }, + }, + { + desc: "Multiple alone Wildcard", + routeSpec: v1alpha1.HTTPRouteSpec{ + Hostnames: []v1alpha1.Hostname{ + "foo.foo", + "*.*", + }, + }, + expectErr: true, + }, + { + desc: "Multiple Wildcard", + routeSpec: v1alpha1.HTTPRouteSpec{ + Hostnames: []v1alpha1.Hostname{ + "foo.foo", + "*.toto.*.bar.foo", + }, + }, + expectErr: true, + }, + { + desc: "Multiple subdomain with misplaced wildcard", + routeSpec: v1alpha1.HTTPRouteSpec{ + Hostnames: []v1alpha1.Hostname{ + "foo.foo", + "toto.*.bar.foo", + }, + }, + expectErr: true, + }, } for _, test := range testCases { test := test t.Run(test.desc, func(t *testing.T) { t.Parallel() + rule, err := hostRule(test.routeSpec) - assert.Equal(t, test.expectedRule, hostRule(test.routeSpec)) + assert.Equal(t, test.expectedRule, rule) + if test.expectErr { + assert.Error(t, err) + } }) } }