From d046af2e91e733db1f909c7108f24865d07a2a8f Mon Sep 17 00:00:00 2001 From: Roman Tomjak <6570684+romantomjak@users.noreply.github.com> Date: Thu, 22 Dec 2022 14:02:05 +0000 Subject: [PATCH] Add support for HTTPRequestRedirectFilter in k8s Gateway API --- .../httproute/filter_http_to_https.yml | 52 +++++++ ...r_http_to_https_with_hostname_and_port.yml | 52 +++++++ pkg/provider/kubernetes/gateway/kubernetes.go | 100 +++++++++++++ .../kubernetes/gateway/kubernetes_test.go | 135 ++++++++++++++++++ 4 files changed, 339 insertions(+) create mode 100644 pkg/provider/kubernetes/gateway/fixtures/httproute/filter_http_to_https.yml create mode 100644 pkg/provider/kubernetes/gateway/fixtures/httproute/filter_http_to_https_with_hostname_and_port.yml diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_http_to_https.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_http_to_https.yml new file mode 100644 index 000000000..5b8d1607d --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_http_to_https.yml @@ -0,0 +1,52 @@ +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1alpha2 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1alpha2 +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: my-gateway-class + listeners: # Use GatewayClass defaults for listener definition. + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + kinds: + - kind: HTTPRoute + group: gateway.networking.k8s.io + namespaces: + from: Same + +--- +kind: HTTPRoute +apiVersion: gateway.networking.k8s.io/v1alpha2 +metadata: + name: http-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + hostnames: + - "example.org" + rules: + - backendRefs: + - name: whoami + port: 80 + weight: 1 + kind: Service + group: "" + filters: + - type: RequestRedirect + requestRedirect: + scheme: https + statusCode: 301 diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_http_to_https_with_hostname_and_port.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_http_to_https_with_hostname_and_port.yml new file mode 100644 index 000000000..79e875be1 --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_http_to_https_with_hostname_and_port.yml @@ -0,0 +1,52 @@ +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1alpha2 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1alpha2 +metadata: + name: my-gateway + namespace: default +spec: + gatewayClassName: my-gateway-class + listeners: # Use GatewayClass defaults for listener definition. + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + kinds: + - kind: HTTPRoute + group: gateway.networking.k8s.io + namespaces: + from: Same + +--- +kind: HTTPRoute +apiVersion: gateway.networking.k8s.io/v1alpha2 +metadata: + name: http-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + hostnames: + - "example.org" + rules: + - backendRefs: + - name: whoami + port: 80 + weight: 1 + kind: Service + group: "" + filters: + - type: RequestRedirect + requestRedirect: + hostname: example.com + port: 443 diff --git a/pkg/provider/kubernetes/gateway/kubernetes.go b/pkg/provider/kubernetes/gateway/kubernetes.go index 847d1c09b..8f25399f6 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes.go +++ b/pkg/provider/kubernetes/gateway/kubernetes.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net" + "net/http" "os" "regexp" "sort" @@ -756,6 +757,26 @@ func gatewayHTTPRouteToHTTPConf(ctx context.Context, ep string, listener v1alpha continue } + middlewares, err := loadMiddlewares(listener, routerKey, routeRule.Filters) + if err != nil { + // update "ResolvedRefs" status true with "InvalidFilters" reason + conditions = append(conditions, metav1.Condition{ + Type: string(v1alpha2.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Now(), + Reason: "InvalidFilters", // TODO check the spec if a proper reason is introduced at some point + Message: fmt.Sprintf("Cannot load HTTPRoute filter %s/%s: %v", route.Namespace, route.Name, err), + }) + + // TODO update the RouteStatus condition / deduplicate conditions on listener + continue + } + + for middlewareName, middleware := range middlewares { + conf.HTTP.Middlewares[middlewareName] = middleware + router.Middlewares = append(router.Middlewares, middlewareName) + } + if len(routeRule.BackendRefs) == 0 { continue } @@ -1663,6 +1684,85 @@ func loadTCPServices(client Client, namespace string, backendRefs []v1alpha2.Bac return wrrSvc, services, nil } +func loadMiddlewares(listener v1alpha2.Listener, prefix string, filters []v1alpha2.HTTPRouteFilter) (map[string]*dynamic.Middleware, error) { + middlewares := make(map[string]*dynamic.Middleware) + + // The spec allows for an empty string in which case we should use the + // scheme of the request which in this case is the listener scheme. + var listenerScheme string + switch listener.Protocol { + case v1alpha2.HTTPProtocolType: + listenerScheme = "http" + case v1alpha2.HTTPSProtocolType: + listenerScheme = "https" + default: + return nil, fmt.Errorf("invalid listener protocol %s", listener.Protocol) + } + + for i, filter := range filters { + var middleware *dynamic.Middleware + switch filter.Type { + case v1alpha2.HTTPRouteFilterRequestRedirect: + var err error + middleware, err = createRedirectRegexMiddleware(listenerScheme, filter.RequestRedirect) + if err != nil { + return nil, fmt.Errorf("creating RedirectRegex middleware: %w", err) + } + default: + // As per the spec: + // https://gateway-api.sigs.k8s.io/api-types/httproute/#filters-optional + // In all cases where incompatible or unsupported filters are + // specified, implementations MUST add a warning condition to + // status. + return nil, fmt.Errorf("unsupported filter %s", filter.Type) + } + + middlewareName := provider.Normalize(fmt.Sprintf("%s-%s-%d", prefix, strings.ToLower(string(filter.Type)), i)) + middlewares[middlewareName] = middleware + } + + return middlewares, nil +} + +func createRedirectRegexMiddleware(scheme string, filter *v1alpha2.HTTPRequestRedirectFilter) (*dynamic.Middleware, error) { + // Use the HTTPRequestRedirectFilter scheme if defined. + filterScheme := scheme + if filter.Scheme != nil { + filterScheme = *filter.Scheme + } + + if filterScheme != "http" && filterScheme != "https" { + return nil, fmt.Errorf("invalid scheme %s", filterScheme) + } + + statusCode := http.StatusFound + if filter.StatusCode != nil { + statusCode = *filter.StatusCode + } + + if statusCode != http.StatusMovedPermanently && statusCode != http.StatusFound { + return nil, fmt.Errorf("invalid status code %d", statusCode) + } + + port := "${port}" + if filter.Port != nil { + port = fmt.Sprintf(":%d", *filter.Port) + } + + hostname := "${hostname}" + if filter.Hostname != nil && *filter.Hostname != "" { + hostname = string(*filter.Hostname) + } + + return &dynamic.Middleware{ + RedirectRegex: &dynamic.RedirectRegex{ + Regex: `^[a-z]+:\/\/(?P.+@)?(?P\[[\w:\.]+\]|[\w\._-]+)(?P:\d+)?\/(?P.*)`, + Replacement: fmt.Sprintf("%s://${userinfo}%s%s/${path}", filterScheme, hostname, port), + Permanent: statusCode == http.StatusMovedPermanently, + }, + }, nil +} + func getProtocol(portSpec corev1.ServicePort) string { protocol := "http" if portSpec.Port == 443 || strings.HasPrefix(portSpec.Name, "https") { diff --git a/pkg/provider/kubernetes/gateway/kubernetes_test.go b/pkg/provider/kubernetes/gateway/kubernetes_test.go index 28946ba7b..068d37d3b 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes_test.go +++ b/pkg/provider/kubernetes/gateway/kubernetes_test.go @@ -1555,6 +1555,141 @@ func TestLoadHTTPRoutes(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Simple HTTPRoute, redirect HTTP to HTTPS", + paths: []string{"services.yml", "httproute/filter_http_to_https.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{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + ServersTransports: map[string]*dynamic.TCPServersTransport{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4": { + EntryPoints: []string{"web"}, + Service: "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-wrr", + Rule: "Host(`example.org`) && PathPrefix(`/`)", + Middlewares: []string{"default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-requestredirect-0"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-requestredirect-0": { + RedirectRegex: &dynamic.RedirectRegex{ + Regex: "^[a-z]+:\\/\\/(?P.+@)?(?P\\[[\\w:\\.]+\\]|[\\w\\._-]+)(?P:\\d+)?\\/(?P.*)", + Replacement: "https://${userinfo}${hostname}${port}/${path}", + Permanent: true, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-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: pointer.Bool(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Simple HTTPRoute, redirect HTTP to HTTPS with hostname", + paths: []string{"services.yml", "httproute/filter_http_to_https_with_hostname_and_port.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{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + ServersTransports: map[string]*dynamic.TCPServersTransport{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4": { + EntryPoints: []string{"web"}, + Service: "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-wrr", + Rule: "Host(`example.org`) && PathPrefix(`/`)", + Middlewares: []string{"default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-requestredirect-0"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-requestredirect-0": { + RedirectRegex: &dynamic.RedirectRegex{ + Regex: "^[a-z]+:\\/\\/(?P.+@)?(?P\\[[\\w:\\.]+\\]|[\\w\\._-]+)(?P:\\d+)?\\/(?P.*)", + Replacement: "http://${userinfo}example.com:443/${path}", + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-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: pointer.Bool(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, } for _, test := range testCases {