diff --git a/integration/k8s_conformance_test.go b/integration/k8s_conformance_test.go index 0c1baa28e..0e2b5e855 100644 --- a/integration/k8s_conformance_test.go +++ b/integration/k8s_conformance_test.go @@ -219,8 +219,6 @@ func (s *K8sConformanceSuite) TestK8sGatewayAPIConformance() { SkipTests: []string{ tests.HTTPRouteMethodMatching.ShortName, tests.HTTPRouteQueryParamMatching.ShortName, - tests.HTTPRouteRewriteHost.ShortName, - tests.HTTPRouteRewritePath.ShortName, }, } diff --git a/pkg/config/dynamic/middlewares.go b/pkg/config/dynamic/middlewares.go index 79462e9df..e26f0ca57 100644 --- a/pkg/config/dynamic/middlewares.go +++ b/pkg/config/dynamic/middlewares.go @@ -43,6 +43,7 @@ type Middleware struct { // Gateway API HTTPRoute filters middlewares. RequestHeaderModifier *RequestHeaderModifier `json:"requestHeaderModifier,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` RequestRedirect *RequestRedirect `json:"requestRedirect,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` + URLRewrite *URLRewrite `json:"URLRewrite,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` } // +k8s:deepcopy-gen=true @@ -703,3 +704,12 @@ type RequestRedirect struct { PathPrefix *string `json:"pathPrefix,omitempty"` StatusCode int `json:"statusCode,omitempty"` } + +// +k8s:deepcopy-gen=true + +// URLRewrite holds the URL rewrite middleware configuration. +type URLRewrite struct { + Hostname *string `json:"hostname,omitempty"` + Path *string `json:"path,omitempty"` + PathPrefix *string `json:"pathPrefix,omitempty"` +} diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index 9b8d26217..bf34dd55a 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -869,6 +869,11 @@ func (in *Middleware) DeepCopyInto(out *Middleware) { *out = new(RequestRedirect) (*in).DeepCopyInto(*out) } + if in.URLRewrite != nil { + in, out := &in.URLRewrite, &out.URLRewrite + *out = new(URLRewrite) + (*in).DeepCopyInto(*out) + } return } @@ -2205,6 +2210,37 @@ func (in *UDPWeightedRoundRobin) DeepCopy() *UDPWeightedRoundRobin { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *URLRewrite) DeepCopyInto(out *URLRewrite) { + *out = *in + if in.Hostname != nil { + in, out := &in.Hostname, &out.Hostname + *out = new(string) + **out = **in + } + if in.Path != nil { + in, out := &in.Path, &out.Path + *out = new(string) + **out = **in + } + if in.PathPrefix != nil { + in, out := &in.PathPrefix, &out.PathPrefix + *out = new(string) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new URLRewrite. +func (in *URLRewrite) DeepCopy() *URLRewrite { + if in == nil { + return nil + } + out := new(URLRewrite) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in Users) DeepCopyInto(out *Users) { { diff --git a/pkg/middlewares/gatewayapi/headermodifier/request_header_modifier.go b/pkg/middlewares/gatewayapi/headermodifier/request_header_modifier.go index 3b197c2d5..1f4536362 100644 --- a/pkg/middlewares/gatewayapi/headermodifier/request_header_modifier.go +++ b/pkg/middlewares/gatewayapi/headermodifier/request_header_modifier.go @@ -22,7 +22,7 @@ type requestHeaderModifier struct { } // NewRequestHeaderModifier creates a new request header modifier middleware. -func NewRequestHeaderModifier(ctx context.Context, next http.Handler, config dynamic.RequestHeaderModifier, name string) (http.Handler, error) { +func NewRequestHeaderModifier(ctx context.Context, next http.Handler, config dynamic.RequestHeaderModifier, name string) http.Handler { logger := middlewares.GetLogger(ctx, name, typeName) logger.Debug().Msg("Creating middleware") @@ -32,7 +32,7 @@ func NewRequestHeaderModifier(ctx context.Context, next http.Handler, config dyn set: config.Set, add: config.Add, remove: config.Remove, - }, nil + } } func (r *requestHeaderModifier) GetTracingInformation() (string, string, trace.SpanKind) { diff --git a/pkg/middlewares/gatewayapi/headermodifier/request_header_modifier_test.go b/pkg/middlewares/gatewayapi/headermodifier/request_header_modifier_test.go index 60c446ecc..d522d9850 100644 --- a/pkg/middlewares/gatewayapi/headermodifier/request_header_modifier_test.go +++ b/pkg/middlewares/gatewayapi/headermodifier/request_header_modifier_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/testhelpers" ) @@ -104,8 +103,7 @@ func TestRequestHeaderModifier(t *testing.T) { gotHeaders = r.Header }) - handler, err := NewRequestHeaderModifier(context.Background(), next, test.config, "foo-request-header-modifier") - require.NoError(t, err) + handler := NewRequestHeaderModifier(context.Background(), next, test.config, "foo-request-header-modifier") req := testhelpers.MustNewRequest(http.MethodGet, "http://localhost", nil) if test.requestHeaders != nil { diff --git a/pkg/middlewares/gatewayapi/redirect/request_redirect.go b/pkg/middlewares/gatewayapi/redirect/request_redirect.go index b0ec9d7fe..9258b652f 100644 --- a/pkg/middlewares/gatewayapi/redirect/request_redirect.go +++ b/pkg/middlewares/gatewayapi/redirect/request_redirect.go @@ -18,8 +18,9 @@ const ( ) type redirect struct { - name string - next http.Handler + name string + next http.Handler + scheme *string hostname *string port *string diff --git a/pkg/middlewares/gatewayapi/redirect/request_redirect_test.go b/pkg/middlewares/gatewayapi/redirect/request_redirect_test.go index 97d324ced..68eb19bd3 100644 --- a/pkg/middlewares/gatewayapi/redirect/request_redirect_test.go +++ b/pkg/middlewares/gatewayapi/redirect/request_redirect_test.go @@ -2,7 +2,6 @@ package redirect import ( "context" - "crypto/tls" "net/http" "net/http/httptest" "testing" @@ -15,15 +14,12 @@ import ( func TestRequestRedirectHandler(t *testing.T) { testCases := []struct { - desc string - config dynamic.RequestRedirect - method string - url string - headers map[string]string - secured bool - expectedURL string - expectedStatus int - errorExpected bool + desc string + config dynamic.RequestRedirect + url string + wantURL string + wantStatus int + wantErr bool }{ { desc: "wrong status code", @@ -31,44 +27,44 @@ func TestRequestRedirectHandler(t *testing.T) { Path: ptr.To("/baz"), StatusCode: http.StatusOK, }, - url: "http://foo.com:80/foo/bar", - errorExpected: true, + url: "http://foo.com:80/foo/bar", + wantErr: true, }, { desc: "replace path", config: dynamic.RequestRedirect{ Path: ptr.To("/baz"), }, - url: "http://foo.com:80/foo/bar", - expectedURL: "http://foo.com:80/baz", - expectedStatus: http.StatusFound, + url: "http://foo.com:80/foo/bar", + wantURL: "http://foo.com:80/baz", + wantStatus: http.StatusFound, }, { desc: "replace path without trailing slash", config: dynamic.RequestRedirect{ Path: ptr.To("/baz"), }, - url: "http://foo.com:80/foo/bar/", - expectedURL: "http://foo.com:80/baz", - expectedStatus: http.StatusFound, + url: "http://foo.com:80/foo/bar/", + wantURL: "http://foo.com:80/baz", + wantStatus: http.StatusFound, }, { desc: "replace path with trailing slash", config: dynamic.RequestRedirect{ Path: ptr.To("/baz/"), }, - url: "http://foo.com:80/foo/bar", - expectedURL: "http://foo.com:80/baz/", - expectedStatus: http.StatusFound, + url: "http://foo.com:80/foo/bar", + wantURL: "http://foo.com:80/baz/", + wantStatus: http.StatusFound, }, { desc: "only hostname", config: dynamic.RequestRedirect{ Hostname: ptr.To("bar.com"), }, - url: "http://foo.com:8080/foo/", - expectedURL: "http://bar.com:8080/foo/", - expectedStatus: http.StatusFound, + url: "http://foo.com:8080/foo/", + wantURL: "http://bar.com:8080/foo/", + wantStatus: http.StatusFound, }, { desc: "replace prefix path", @@ -76,9 +72,9 @@ func TestRequestRedirectHandler(t *testing.T) { Path: ptr.To("/baz"), PathPrefix: ptr.To("/foo"), }, - url: "http://foo.com:80/foo/bar", - expectedURL: "http://foo.com:80/baz/bar", - expectedStatus: http.StatusFound, + url: "http://foo.com:80/foo/bar", + wantURL: "http://foo.com:80/baz/bar", + wantStatus: http.StatusFound, }, { desc: "replace prefix path with trailing slash", @@ -86,9 +82,9 @@ func TestRequestRedirectHandler(t *testing.T) { Path: ptr.To("/baz"), PathPrefix: ptr.To("/foo"), }, - url: "http://foo.com:80/foo/bar/", - expectedURL: "http://foo.com:80/baz/bar/", - expectedStatus: http.StatusFound, + url: "http://foo.com:80/foo/bar/", + wantURL: "http://foo.com:80/baz/bar/", + wantStatus: http.StatusFound, }, { desc: "replace prefix path without slash prefix", @@ -96,9 +92,9 @@ func TestRequestRedirectHandler(t *testing.T) { Path: ptr.To("baz"), PathPrefix: ptr.To("/foo"), }, - url: "http://foo.com:80/foo/bar", - expectedURL: "http://foo.com:80/baz/bar", - expectedStatus: http.StatusFound, + url: "http://foo.com:80/foo/bar", + wantURL: "http://foo.com:80/baz/bar", + wantStatus: http.StatusFound, }, { desc: "replace prefix path without slash prefix", @@ -106,9 +102,9 @@ func TestRequestRedirectHandler(t *testing.T) { Path: ptr.To("/baz"), PathPrefix: ptr.To("/foo/"), }, - url: "http://foo.com:80/foo/bar", - expectedURL: "http://foo.com:80/baz/bar", - expectedStatus: http.StatusFound, + url: "http://foo.com:80/foo/bar", + wantURL: "http://foo.com:80/baz/bar", + wantStatus: http.StatusFound, }, { desc: "simple redirection", @@ -117,9 +113,9 @@ func TestRequestRedirectHandler(t *testing.T) { Hostname: ptr.To("foobar.com"), Port: ptr.To("443"), }, - url: "http://foo.com:80", - expectedURL: "https://foobar.com:443", - expectedStatus: http.StatusFound, + url: "http://foo.com:80", + wantURL: "https://foobar.com:443", + wantStatus: http.StatusFound, }, { desc: "HTTP to HTTPS permanent", @@ -127,9 +123,9 @@ func TestRequestRedirectHandler(t *testing.T) { Scheme: ptr.To("https"), StatusCode: http.StatusMovedPermanently, }, - url: "http://foo", - expectedURL: "https://foo", - expectedStatus: http.StatusMovedPermanently, + url: "http://foo", + wantURL: "https://foo", + wantStatus: http.StatusMovedPermanently, }, { desc: "HTTPS to HTTP permanent", @@ -137,10 +133,9 @@ func TestRequestRedirectHandler(t *testing.T) { Scheme: ptr.To("http"), StatusCode: http.StatusMovedPermanently, }, - secured: true, - url: "https://foo", - expectedURL: "http://foo", - expectedStatus: http.StatusMovedPermanently, + url: "https://foo", + wantURL: "http://foo", + wantStatus: http.StatusMovedPermanently, }, { desc: "HTTP to HTTPS", @@ -148,9 +143,9 @@ func TestRequestRedirectHandler(t *testing.T) { Scheme: ptr.To("https"), Port: ptr.To("443"), }, - url: "http://foo:80", - expectedURL: "https://foo:443", - expectedStatus: http.StatusFound, + url: "http://foo:80", + wantURL: "https://foo:443", + wantStatus: http.StatusFound, }, { desc: "HTTP to HTTPS, with X-Forwarded-Proto", @@ -158,12 +153,9 @@ func TestRequestRedirectHandler(t *testing.T) { Scheme: ptr.To("https"), Port: ptr.To("443"), }, - url: "http://foo:80", - headers: map[string]string{ - "X-Forwarded-Proto": "https", - }, - expectedURL: "https://foo:443", - expectedStatus: http.StatusFound, + url: "http://foo:80", + wantURL: "https://foo:443", + wantStatus: http.StatusFound, }, { desc: "HTTPS to HTTP", @@ -171,10 +163,9 @@ func TestRequestRedirectHandler(t *testing.T) { Scheme: ptr.To("http"), Port: ptr.To("80"), }, - secured: true, - url: "https://foo:443", - expectedURL: "http://foo:80", - expectedStatus: http.StatusFound, + url: "https://foo:443", + wantURL: "http://foo:80", + wantStatus: http.StatusFound, }, { desc: "HTTP to HTTP", @@ -182,9 +173,9 @@ func TestRequestRedirectHandler(t *testing.T) { Scheme: ptr.To("http"), Port: ptr.To("88"), }, - url: "http://foo:80", - expectedURL: "http://foo:88", - expectedStatus: http.StatusFound, + url: "http://foo:80", + wantURL: "http://foo:88", + wantStatus: http.StatusFound, }, } @@ -193,45 +184,32 @@ func TestRequestRedirectHandler(t *testing.T) { t.Parallel() next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) - handler, err := NewRequestRedirect(context.Background(), next, test.config, "traefikTest") - if test.errorExpected { + handler, err := NewRequestRedirect(context.Background(), next, test.config, "traefikTest") + if test.wantErr { require.Error(t, err) require.Nil(t, handler) - } else { + return + } + + require.NoError(t, err) + require.NotNil(t, handler) + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, test.url, nil) + + handler.ServeHTTP(recorder, req) + + assert.Equal(t, test.wantStatus, recorder.Code) + switch test.wantStatus { + case http.StatusMovedPermanently, http.StatusFound: + location, err := recorder.Result().Location() require.NoError(t, err) - require.NotNil(t, handler) - recorder := httptest.NewRecorder() - - method := http.MethodGet - if test.method != "" { - method = test.method - } - - req := httptest.NewRequest(method, test.url, nil) - if test.secured { - req.TLS = &tls.ConnectionState{} - } - - for k, v := range test.headers { - req.Header.Set(k, v) - } - - req.Header.Set("X-Foo", "bar") - handler.ServeHTTP(recorder, req) - - assert.Equal(t, test.expectedStatus, recorder.Code) - switch test.expectedStatus { - case http.StatusMovedPermanently, http.StatusFound: - location, err := recorder.Result().Location() - require.NoError(t, err) - - assert.Equal(t, test.expectedURL, location.String()) - default: - location, err := recorder.Result().Location() - require.Errorf(t, err, "Location %v", location) - } + assert.Equal(t, test.wantURL, location.String()) + default: + location, err := recorder.Result().Location() + require.Errorf(t, err, "Location %v", location) } }) } diff --git a/pkg/middlewares/gatewayapi/urlrewrite/url_rewrite.go b/pkg/middlewares/gatewayapi/urlrewrite/url_rewrite.go new file mode 100644 index 000000000..2960a5583 --- /dev/null +++ b/pkg/middlewares/gatewayapi/urlrewrite/url_rewrite.go @@ -0,0 +1,68 @@ +package urlrewrite + +import ( + "context" + "net/http" + "path" + "strings" + + "github.com/traefik/traefik/v3/pkg/config/dynamic" + "github.com/traefik/traefik/v3/pkg/middlewares" + "go.opentelemetry.io/otel/trace" +) + +const ( + typeName = "URLRewrite" +) + +type urlRewrite struct { + name string + next http.Handler + + hostname *string + path *string + pathPrefix *string +} + +// NewURLRewrite creates a URL rewrite middleware. +func NewURLRewrite(ctx context.Context, next http.Handler, conf dynamic.URLRewrite, name string) http.Handler { + logger := middlewares.GetLogger(ctx, name, typeName) + logger.Debug().Msg("Creating middleware") + + return urlRewrite{ + name: name, + next: next, + hostname: conf.Hostname, + path: conf.Path, + pathPrefix: conf.PathPrefix, + } +} + +func (u urlRewrite) GetTracingInformation() (string, string, trace.SpanKind) { + return u.name, typeName, trace.SpanKindInternal +} + +func (u urlRewrite) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + newPath := req.URL.Path + if u.path != nil && u.pathPrefix == nil { + newPath = *u.path + } + if u.path != nil && u.pathPrefix != nil { + newPath = path.Join(*u.path, strings.TrimPrefix(req.URL.Path, *u.pathPrefix)) + + // add the trailing slash if needed, as path.Join removes trailing slashes. + if strings.HasSuffix(req.URL.Path, "/") && !strings.HasSuffix(newPath, "/") { + newPath += "/" + } + } + + req.URL.Path = newPath + req.URL.RawPath = req.URL.EscapedPath() + req.RequestURI = req.URL.RequestURI() + + if u.hostname != nil { + req.Host = *u.hostname + } + + u.next.ServeHTTP(rw, req) +} diff --git a/pkg/middlewares/gatewayapi/urlrewrite/url_rewrite_test.go b/pkg/middlewares/gatewayapi/urlrewrite/url_rewrite_test.go new file mode 100644 index 000000000..6b2b9edab --- /dev/null +++ b/pkg/middlewares/gatewayapi/urlrewrite/url_rewrite_test.go @@ -0,0 +1,126 @@ +package urlrewrite + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/traefik/traefik/v3/pkg/config/dynamic" + "k8s.io/utils/ptr" +) + +func TestURLRewriteHandler(t *testing.T) { + testCases := []struct { + desc string + config dynamic.URLRewrite + url string + wantURL string + wantHost string + }{ + { + desc: "replace path", + config: dynamic.URLRewrite{ + Path: ptr.To("/baz"), + }, + url: "http://foo.com/foo/bar", + wantURL: "http://foo.com/baz", + wantHost: "foo.com", + }, + { + desc: "replace path without trailing slash", + config: dynamic.URLRewrite{ + Path: ptr.To("/baz"), + }, + url: "http://foo.com/foo/bar/", + wantURL: "http://foo.com/baz", + wantHost: "foo.com", + }, + { + desc: "replace path with trailing slash", + config: dynamic.URLRewrite{ + Path: ptr.To("/baz/"), + }, + url: "http://foo.com/foo/bar", + wantURL: "http://foo.com/baz/", + wantHost: "foo.com", + }, + { + desc: "only host", + config: dynamic.URLRewrite{ + Hostname: ptr.To("bar.com"), + }, + url: "http://foo.com/foo/", + wantURL: "http://foo.com/foo/", + wantHost: "bar.com", + }, + { + desc: "host and path", + config: dynamic.URLRewrite{ + Hostname: ptr.To("bar.com"), + Path: ptr.To("/baz/"), + }, + url: "http://foo.com/foo/", + wantURL: "http://foo.com/baz/", + wantHost: "bar.com", + }, + { + desc: "replace prefix path", + config: dynamic.URLRewrite{ + Path: ptr.To("/baz"), + PathPrefix: ptr.To("/foo"), + }, + url: "http://foo.com/foo/bar", + wantURL: "http://foo.com/baz/bar", + wantHost: "foo.com", + }, + { + desc: "replace prefix path with trailing slash", + config: dynamic.URLRewrite{ + Path: ptr.To("/baz"), + PathPrefix: ptr.To("/foo"), + }, + url: "http://foo.com/foo/bar/", + wantURL: "http://foo.com/baz/bar/", + wantHost: "foo.com", + }, + { + desc: "replace prefix path without slash prefix", + config: dynamic.URLRewrite{ + Path: ptr.To("baz"), + PathPrefix: ptr.To("/foo"), + }, + url: "http://foo.com/foo/bar", + wantURL: "http://foo.com/baz/bar", + wantHost: "foo.com", + }, + { + desc: "replace prefix path without slash prefix", + config: dynamic.URLRewrite{ + Path: ptr.To("/baz"), + PathPrefix: ptr.To("/foo/"), + }, + url: "http://foo.com/foo/bar", + wantURL: "http://foo.com/baz/bar", + wantHost: "foo.com", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + + handler := NewURLRewrite(context.Background(), next, test.config, "traefikTest") + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, test.url, nil) + handler.ServeHTTP(recorder, req) + + assert.Equal(t, test.wantURL, req.URL.String()) + assert.Equal(t, test.wantHost, req.Host) + }) + } +} diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_url_rewrite_combined.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_url_rewrite_combined.yml new file mode 100644 index 000000000..90d432572 --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_url_rewrite_combined.yml @@ -0,0 +1,58 @@ +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +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/v1 +metadata: + name: http-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + hostnames: + - "example.com" + rules: + - matches: + - path: + type: PathPrefix + value: /foo + backendRefs: + - name: whoami + port: 80 + weight: 1 + kind: Service + group: "" + filters: + - type: URLRewrite + urlRewrite: + hostname: www.foo.bar + path: + type: ReplacePrefixMatch + replacePrefixMatch: /xyz diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_url_rewrite_fullpath.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_url_rewrite_fullpath.yml new file mode 100644 index 000000000..f1183610a --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_url_rewrite_fullpath.yml @@ -0,0 +1,57 @@ +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +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/v1 +metadata: + name: http-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + hostnames: + - "example.com" + rules: + - matches: + - path: + type: PathPrefix + value: /foo + backendRefs: + - name: whoami + port: 80 + weight: 1 + kind: Service + group: "" + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplaceFullPath + replaceFullPath: /bar diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_url_rewrite_hostname.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_url_rewrite_hostname.yml new file mode 100644 index 000000000..854d2eac2 --- /dev/null +++ b/pkg/provider/kubernetes/gateway/fixtures/httproute/filter_url_rewrite_hostname.yml @@ -0,0 +1,55 @@ +--- +kind: GatewayClass +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: my-gateway-class +spec: + controllerName: traefik.io/gateway-controller + +--- +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +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/v1 +metadata: + name: http-app-1 + namespace: default +spec: + parentRefs: + - name: my-gateway + kind: Gateway + group: gateway.networking.k8s.io + hostnames: + - "example.com" + rules: + - matches: + - path: + type: PathPrefix + value: /foo + backendRefs: + - name: whoami + port: 80 + weight: 1 + kind: Service + group: "" + filters: + - type: URLRewrite + urlRewrite: + hostname: www.foo.bar diff --git a/pkg/provider/kubernetes/gateway/httproute.go b/pkg/provider/kubernetes/gateway/httproute.go index 3f378002e..4d47869bf 100644 --- a/pkg/provider/kubernetes/gateway/httproute.go +++ b/pkg/provider/kubernetes/gateway/httproute.go @@ -301,15 +301,19 @@ func (p *Provider) loadHTTPBackendRef(namespace string, backendRef gatev1.HTTPBa } func (p *Provider) loadMiddlewares(conf *dynamic.Configuration, namespace, routerName string, filters []gatev1.HTTPRouteFilter, pathMatch *gatev1.HTTPPathMatch) ([]string, error) { + pm := ptr.Deref(pathMatch, gatev1.HTTPPathMatch{ + Type: ptr.To(gatev1.PathMatchPathPrefix), + Value: ptr.To("/"), + }) + middlewares := make(map[string]*dynamic.Middleware) for i, filter := range filters { + name := fmt.Sprintf("%s-%s-%d", routerName, strings.ToLower(string(filter.Type)), i) switch filter.Type { case gatev1.HTTPRouteFilterRequestRedirect: - name := fmt.Sprintf("%s-%s-%d", routerName, strings.ToLower(string(filter.Type)), i) - middlewares[name] = createRequestRedirect(filter.RequestRedirect, pathMatch) + middlewares[name] = createRequestRedirect(filter.RequestRedirect, pm) case gatev1.HTTPRouteFilterRequestHeaderModifier: - name := fmt.Sprintf("%s-%s-%d", routerName, strings.ToLower(string(filter.Type)), i) middlewares[name] = createRequestHeaderModifier(filter.RequestHeaderModifier) case gatev1.HTTPRouteFilterExtensionRef: @@ -320,6 +324,14 @@ func (p *Provider) loadMiddlewares(conf *dynamic.Configuration, namespace, route middlewares[name] = middleware + case gatev1.HTTPRouteFilterURLRewrite: + var err error + middleware, err := createURLRewrite(filter.URLRewrite, pm) + if err != nil { + return nil, fmt.Errorf("invalid filter %s: %w", filter.Type, err) + } + middlewares[name] = middleware + 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 @@ -560,7 +572,7 @@ func createRequestHeaderModifier(filter *gatev1.HTTPHeaderFilter) *dynamic.Middl } } -func createRequestRedirect(filter *gatev1.HTTPRequestRedirectFilter, pathMatch *gatev1.HTTPPathMatch) *dynamic.Middleware { +func createRequestRedirect(filter *gatev1.HTTPRequestRedirectFilter, pathMatch gatev1.HTTPPathMatch) *dynamic.Middleware { var hostname *string if filter.Hostname != nil { hostname = ptr.To(string(*filter.Hostname)) @@ -599,6 +611,37 @@ func createRequestRedirect(filter *gatev1.HTTPRequestRedirectFilter, pathMatch * } } +func createURLRewrite(filter *gatev1.HTTPURLRewriteFilter, pathMatch gatev1.HTTPPathMatch) (*dynamic.Middleware, error) { + if filter.Path == nil && filter.Hostname == nil { + return nil, errors.New("empty configuration") + } + + var host *string + if filter.Hostname != nil { + host = ptr.To(string(*filter.Hostname)) + } + + var path *string + var pathPrefix *string + if filter.Path != nil { + switch filter.Path.Type { + case gatev1.FullPathHTTPPathModifier: + path = filter.Path.ReplaceFullPath + case gatev1.PrefixMatchHTTPPathModifier: + path = filter.Path.ReplacePrefixMatch + pathPrefix = pathMatch.Value + } + } + + return &dynamic.Middleware{ + URLRewrite: &dynamic.URLRewrite{ + Hostname: host, + Path: path, + PathPrefix: pathPrefix, + }, + }, 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 b404931d8..af87073df 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes_test.go +++ b/pkg/provider/kubernetes/gateway/kubernetes_test.go @@ -1734,6 +1734,212 @@ func TestLoadHTTPRoutes(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Simple HTTPRoute URL rewrite FullPath", + paths: []string{"services.yml", "httproute/filter_url_rewrite_fullpath.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-0-7f90cf546b15efadf2f8": { + EntryPoints: []string{"web"}, + Service: "default-http-app-1-my-gateway-web-0-wrr", + Rule: "Host(`example.com`) && (Path(`/foo`) || PathPrefix(`/foo/`))", + RuleSyntax: "v3", + Priority: 10412, + Middlewares: []string{"default-http-app-1-my-gateway-web-0-7f90cf546b15efadf2f8-urlrewrite-0"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-http-app-1-my-gateway-web-0-7f90cf546b15efadf2f8-urlrewrite-0": { + URLRewrite: &dynamic.URLRewrite{ + Path: ptr.To("/bar"), + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-http-app-1-my-gateway-web-0-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: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Simple HTTPRoute URL rewrite Hostname", + paths: []string{"services.yml", "httproute/filter_url_rewrite_hostname.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-0-7f90cf546b15efadf2f8": { + EntryPoints: []string{"web"}, + Service: "default-http-app-1-my-gateway-web-0-wrr", + Rule: "Host(`example.com`) && (Path(`/foo`) || PathPrefix(`/foo/`))", + RuleSyntax: "v3", + Priority: 10412, + Middlewares: []string{"default-http-app-1-my-gateway-web-0-7f90cf546b15efadf2f8-urlrewrite-0"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-http-app-1-my-gateway-web-0-7f90cf546b15efadf2f8-urlrewrite-0": { + URLRewrite: &dynamic.URLRewrite{ + Hostname: ptr.To("www.foo.bar"), + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-http-app-1-my-gateway-web-0-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: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "Simple HTTPRoute URL rewrite Combined", + paths: []string{"services.yml", "httproute/filter_url_rewrite_combined.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-0-7f90cf546b15efadf2f8": { + EntryPoints: []string{"web"}, + Service: "default-http-app-1-my-gateway-web-0-wrr", + Rule: "Host(`example.com`) && (Path(`/foo`) || PathPrefix(`/foo/`))", + RuleSyntax: "v3", + Priority: 10412, + Middlewares: []string{"default-http-app-1-my-gateway-web-0-7f90cf546b15efadf2f8-urlrewrite-0"}, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-http-app-1-my-gateway-web-0-7f90cf546b15efadf2f8-urlrewrite-0": { + URLRewrite: &dynamic.URLRewrite{ + Hostname: ptr.To("www.foo.bar"), + Path: ptr.To("/xyz"), + PathPrefix: ptr.To("/foo"), + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-http-app-1-my-gateway-web-0-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: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: ptypes.Duration(100 * time.Millisecond), + }, + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{}, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, } for _, test := range testCases { diff --git a/pkg/server/middleware/middlewares.go b/pkg/server/middleware/middlewares.go index b5d0447d8..8b8ee16b9 100644 --- a/pkg/server/middleware/middlewares.go +++ b/pkg/server/middleware/middlewares.go @@ -22,6 +22,7 @@ import ( "github.com/traefik/traefik/v3/pkg/middlewares/customerrors" "github.com/traefik/traefik/v3/pkg/middlewares/gatewayapi/headermodifier" gapiredirect "github.com/traefik/traefik/v3/pkg/middlewares/gatewayapi/redirect" + "github.com/traefik/traefik/v3/pkg/middlewares/gatewayapi/urlrewrite" "github.com/traefik/traefik/v3/pkg/middlewares/grpcweb" "github.com/traefik/traefik/v3/pkg/middlewares/headers" "github.com/traefik/traefik/v3/pkg/middlewares/inflightreq" @@ -392,7 +393,7 @@ func (b *Builder) buildConstructor(ctx context.Context, middlewareName string) ( return nil, badConf } middleware = func(next http.Handler) (http.Handler, error) { - return headermodifier.NewRequestHeaderModifier(ctx, next, *config.RequestHeaderModifier, middlewareName) + return headermodifier.NewRequestHeaderModifier(ctx, next, *config.RequestHeaderModifier, middlewareName), nil } } @@ -405,6 +406,15 @@ func (b *Builder) buildConstructor(ctx context.Context, middlewareName string) ( } } + if config.URLRewrite != nil { + if middleware != nil { + return nil, badConf + } + middleware = func(next http.Handler) (http.Handler, error) { + return urlrewrite.NewURLRewrite(ctx, next, *config.URLRewrite, middlewareName), nil + } + } + if middleware == nil { return nil, fmt.Errorf("invalid middleware %q configuration: invalid middleware type or middleware does not exist", middlewareName) }