diff --git a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md index 8b60b7a4af..c4b986d926 100644 --- a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md +++ b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md @@ -339,6 +339,7 @@ The following annotations are organized by category for easier navigation. | `nginx.ingress.kubernetes.io/canary-by-cookie` | | | `nginx.ingress.kubernetes.io/canary-weight` | | | `nginx.ingress.kubernetes.io/canary-weight-total` | | +| `nginx.ingress.kubernetes.io/x-forwarded-prefix` | | ### CORS @@ -488,7 +489,6 @@ In practice, Traefik is slightly more lenient under bursty load, as it smooths o | `nginx.ingress.kubernetes.io/mirror-request-body` | | | `nginx.ingress.kubernetes.io/mirror-target` | | | `nginx.ingress.kubernetes.io/mirror-host` | | -| `nginx.ingress.kubernetes.io/x-forwarded-prefix` | | | `nginx.ingress.kubernetes.io/denylist-source-range` | | | `nginx.ingress.kubernetes.io/stream-snippet` | | diff --git a/pkg/config/dynamic/middlewares.go b/pkg/config/dynamic/middlewares.go index 81cca16d5c..38e67d0ef6 100644 --- a/pkg/config/dynamic/middlewares.go +++ b/pkg/config/dynamic/middlewares.go @@ -59,6 +59,7 @@ type Middleware struct { // ingress-nginx middlewares. AuthTLSPassCertificateToUpstream *AuthTLSPassCertificateToUpstream `json:"authTLSPassCertificateToUpstream,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` Snippet *Snippet `json:"snippet,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` + RewriteTarget *RewriteTarget `json:"rewriteTarget,omitempty" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` } // +k8s:deepcopy-gen=true @@ -904,3 +905,16 @@ type Snippet struct { ServerSnippet string `json:"serverSnippet,omitempty"` ConfigurationSnippet string `json:"configurationSnippet,omitempty"` } + +// +k8s:deepcopy-gen=true + +// RewriteTarget holds the rewrite target middleware configuration used by ingress-nginx provider. +// This middleware replaces the path of a URL. +type RewriteTarget struct { + // Regex defines the regular expression used to match and capture the path from the request URL. + Regex string `json:"regex,omitempty"` + // Replacement defines the replacement path format, which can include captured variables. + Replacement string `json:"replacement,omitempty"` + // XForwardedPrefix defines the value of the X-Forwarded-Prefix header. + XForwardedPrefix string `json:"xForwardedPrefix,omitempty"` +} diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index 1727d6c3ac..b3ba0f2bc0 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -1122,6 +1122,11 @@ func (in *Middleware) DeepCopyInto(out *Middleware) { *out = new(Snippet) **out = **in } + if in.RewriteTarget != nil { + in, out := &in.RewriteTarget, &out.RewriteTarget + *out = new(RewriteTarget) + **out = **in + } return } @@ -1491,6 +1496,22 @@ func (in *Retry) DeepCopy() *Retry { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RewriteTarget) DeepCopyInto(out *RewriteTarget) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RewriteTarget. +func (in *RewriteTarget) DeepCopy() *RewriteTarget { + if in == nil { + return nil + } + out := new(RewriteTarget) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Router) DeepCopyInto(out *Router) { *out = *in diff --git a/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target.go b/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target.go new file mode 100644 index 0000000000..c91de3c3e6 --- /dev/null +++ b/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target.go @@ -0,0 +1,100 @@ +package rewritetarget + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/traefik/traefik/v3/pkg/config/dynamic" + "github.com/traefik/traefik/v3/pkg/middlewares" + "github.com/traefik/traefik/v3/pkg/middlewares/observability" +) + +const ( + typeName = "RewriteTarget" + xForwardedPrefixHeader = "X-Forwarded-Prefix" +) + +// RewriteTarget is a middleware used to replace the path of a URL request. +type rewriteTarget struct { + next http.Handler + regexp *regexp.Regexp + replacement string + xForwardedPrefix string + name string +} + +// New creates a new rewrite target middleware. +func New(ctx context.Context, next http.Handler, config dynamic.RewriteTarget, name string) (http.Handler, error) { + middlewares.GetLogger(ctx, name, typeName).Debug().Msg("Creating middleware") + + if config.Replacement == "" { + return nil, errors.New("replacement cannot be empty") + } + + mw := &rewriteTarget{ + next: next, + replacement: strings.TrimSpace(config.Replacement), + xForwardedPrefix: config.XForwardedPrefix, + name: name, + } + + if config.Regex != "" { + exp, err := regexp.Compile(strings.TrimSpace(config.Regex)) + if err != nil { + return nil, fmt.Errorf("compiling regular expression %s: %w", config.Regex, err) + } + mw.regexp = exp + } + + return mw, nil +} + +func (rt *rewriteTarget) GetTracingInformation() (string, string) { + return rt.name, typeName +} + +func (rt *rewriteTarget) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + currentPath := req.URL.RawPath + if currentPath == "" { + currentPath = req.URL.EscapedPath() + } + + if rt.regexp != nil { + if !rt.regexp.MatchString(currentPath) { + rt.next.ServeHTTP(rw, req) + return + } + req.URL.RawPath = rt.regexp.ReplaceAllString(currentPath, rt.replacement) + } else { + req.URL.RawPath = rt.replacement + } + + if rt.xForwardedPrefix != "" { + prefix := rt.xForwardedPrefix + if rt.regexp != nil { + prefix = rt.regexp.ReplaceAllString(currentPath, rt.xForwardedPrefix) + } + req.Header.Set(xForwardedPrefixHeader, prefix) + } + + // as replacement can introduce escaped characters + // Path must remain an unescaped version of RawPath + // Doesn't handle multiple times encoded replacement (`/` => `%2F` => `%252F` => ...) + var err error + req.URL.Path, err = url.PathUnescape(req.URL.RawPath) + if err != nil { + middlewares.GetLogger(context.Background(), rt.name, typeName).Error().Msgf("Unable to unescape url raw path %q: %v", req.URL.RawPath, err) + observability.SetStatusErrorf(req.Context(), "Unable to unescape url raw path %q: %v", req.URL.RawPath, err) + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + req.RequestURI = req.URL.RequestURI() + + rt.next.ServeHTTP(rw, req) +} diff --git a/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target_test.go b/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target_test.go new file mode 100644 index 0000000000..38038eedf4 --- /dev/null +++ b/pkg/middlewares/ingressnginx/rewritetarget/rewrite_target_test.go @@ -0,0 +1,180 @@ +package rewritetarget + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traefik/traefik/v3/pkg/config/dynamic" +) + +func TestRewriteTarget(t *testing.T) { + testCases := []struct { + desc string + path string + config dynamic.RewriteTarget + expectedPath string + expectedRawPath string + expectedXForwardedPrefix string + expectsError bool + }{ + { + desc: "empty replacement", + config: dynamic.RewriteTarget{ + Replacement: "", + }, + expectsError: true, + }, + { + desc: "plain replacement", + path: "/foo/bar", + config: dynamic.RewriteTarget{ + Replacement: "/replacement", + }, + expectedPath: "/replacement", + expectedRawPath: "/replacement", + }, + { + desc: "plain replacement with escaped char in replacement", + path: "/foo", + config: dynamic.RewriteTarget{ + Replacement: "/foo%2Fbar", + }, + expectedPath: "/foo/bar", + expectedRawPath: "/foo%2Fbar", + }, + { + desc: "plain replacement with x-forwarded-prefix", + path: "/foo/bar", + config: dynamic.RewriteTarget{ + Replacement: "/replacement", + XForwardedPrefix: "/foo", + }, + expectedPath: "/replacement", + expectedRawPath: "/replacement", + expectedXForwardedPrefix: "/foo", + }, + { + desc: "regex with capture group", + path: "/foo/bar", + config: dynamic.RewriteTarget{ + Regex: `^/foo/(.*)`, + Replacement: "/new/$1", + }, + expectedPath: "/new/bar", + expectedRawPath: "/new/bar", + }, + { + desc: "regex with multiple capture groups", + path: "/downloads/src/source.go", + config: dynamic.RewriteTarget{ + Regex: `^(?i)/downloads/([^/]+)/([^/]+)$`, + Replacement: "/downloads/$1-$2", + }, + expectedPath: "/downloads/src-source.go", + expectedRawPath: "/downloads/src-source.go", + }, + { + desc: "regex with escaped char in replacement", + path: "/aaa/bbb", + config: dynamic.RewriteTarget{ + Regex: `/aaa/bbb`, + Replacement: "/foo%2Fbar", + }, + expectedPath: "/foo/bar", + expectedRawPath: "/foo%2Fbar", + }, + { + desc: "regex - no match passthrough", + path: "/foo/bar", + config: dynamic.RewriteTarget{ + Regex: `^/baz/(.*)`, + Replacement: "/new/$1", + }, + expectedPath: "/foo/bar", + expectedRawPath: "", + }, + { + desc: "invalid regex", + config: dynamic.RewriteTarget{ + Regex: `^(?err)/invalid/regexp/([^/]+)$`, + Replacement: "/valid/$1", + }, + expectsError: true, + }, + { + desc: "regex with x-forwarded-prefix capture group", + path: "/foo/bar", + config: dynamic.RewriteTarget{ + Regex: `^(/foo)/(.*)`, + Replacement: "/$2", + XForwardedPrefix: "$1", + }, + expectedPath: "/bar", + expectedRawPath: "/bar", + expectedXForwardedPrefix: "/foo", + }, + { + desc: "regex with x-forwarded-prefix using third capture group", + path: "/prefix/sub/endpoint", + config: dynamic.RewriteTarget{ + Regex: `^/(prefix)/(sub)/(.*)`, + Replacement: "/$3", + XForwardedPrefix: "/$1/$2", + }, + expectedPath: "/endpoint", + expectedRawPath: "/endpoint", + expectedXForwardedPrefix: "/prefix/sub", + }, + { + desc: "x-forwarded-prefix not set when regex does not match", + path: "/foo/bar", + config: dynamic.RewriteTarget{ + Regex: `^/baz/(.*)`, + Replacement: "/$1", + XForwardedPrefix: "/baz", + }, + expectedPath: "/foo/bar", + expectedRawPath: "", + expectedXForwardedPrefix: "", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + var actualPath, actualRawPath, actualXForwardedPrefix, requestURI string + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + actualPath = r.URL.Path + actualRawPath = r.URL.RawPath + actualXForwardedPrefix = r.Header.Get(xForwardedPrefixHeader) + requestURI = r.RequestURI + }) + + handler, err := New(t.Context(), next, test.config, "test-rewrite-target") + if test.expectsError { + require.Error(t, err) + return + } + require.NoError(t, err) + + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL + test.path) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + assert.Equal(t, test.expectedPath, actualPath, "Unexpected path.") + assert.Equal(t, test.expectedRawPath, actualRawPath, "Unexpected raw path.") + assert.Equal(t, test.expectedXForwardedPrefix, actualXForwardedPrefix, "Unexpected %s header.", xForwardedPrefixHeader) + + if actualRawPath == "" { + assert.Equal(t, actualPath, requestURI, "Unexpected request URI.") + } else { + assert.Equal(t, actualRawPath, requestURI, "Unexpected request URI.") + } + }) + } +} diff --git a/pkg/provider/kubernetes/ingress-nginx/annotations.go b/pkg/provider/kubernetes/ingress-nginx/annotations.go index 25a1d54bab..95a3891e71 100644 --- a/pkg/provider/kubernetes/ingress-nginx/annotations.go +++ b/pkg/provider/kubernetes/ingress-nginx/annotations.go @@ -83,8 +83,9 @@ type ingressConfig struct { LimitRPM *int `annotation:"nginx.ingress.kubernetes.io/limit-rpm"` LimitRPS *int `annotation:"nginx.ingress.kubernetes.io/limit-rps"` - CustomHeaders *string `annotation:"nginx.ingress.kubernetes.io/custom-headers"` - UpstreamVhost *string `annotation:"nginx.ingress.kubernetes.io/upstream-vhost"` + CustomHeaders *string `annotation:"nginx.ingress.kubernetes.io/custom-headers"` + UpstreamVhost *string `annotation:"nginx.ingress.kubernetes.io/upstream-vhost"` + XForwardedPrefix *string `annotation:"nginx.ingress.kubernetes.io/x-forwarded-prefix"` CustomHTTPErrors *[]string `annotation:"nginx.ingress.kubernetes.io/custom-http-errors"` DefaultBackend *string `annotation:"nginx.ingress.kubernetes.io/default-backend"` diff --git a/pkg/provider/kubernetes/ingress-nginx/annotations_test.go b/pkg/provider/kubernetes/ingress-nginx/annotations_test.go index a6abdbee2b..bae98b06c5 100644 --- a/pkg/provider/kubernetes/ingress-nginx/annotations_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/annotations_test.go @@ -38,6 +38,8 @@ func Test_parseIngressConfig(t *testing.T) { "nginx.ingress.kubernetes.io/proxy-buffers-number": "8", "nginx.ingress.kubernetes.io/proxy-max-temp-file-size": "100m", "nginx.ingress.kubernetes.io/limit-rpm": "120", + "nginx.ingress.kubernetes.io/x-forwarded-prefix": "/test", + "nginx.ingress.kubernetes.io/upstream-vhost": "upstream-vhost", }, expected: ingressConfig{ SSLPassthrough: ptr.To(true), @@ -61,6 +63,8 @@ func Test_parseIngressConfig(t *testing.T) { ProxyBuffersNumber: ptr.To(8), ProxyMaxTempFileSize: ptr.To("100m"), LimitRPM: ptr.To(120), + XForwardedPrefix: ptr.To("/test"), + UpstreamVhost: ptr.To("upstream-vhost"), }, }, { diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-x-forwarded-prefix-no-rewrite-target.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-x-forwarded-prefix-no-rewrite-target.yml new file mode 100644 index 0000000000..6aea0e997b --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-x-forwarded-prefix-no-rewrite-target.yml @@ -0,0 +1,22 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-x-forwarded-prefix-no-rewrite-target + namespace: default + annotations: + nginx.ingress.kubernetes.io/x-forwarded-prefix: "x-forwarded-prefix-header-value" + +spec: + ingressClassName: nginx + rules: + - host: x-forwarded-prefix.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-x-forwarded-prefix.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-x-forwarded-prefix.yml new file mode 100644 index 0000000000..e7ecc1a540 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-x-forwarded-prefix.yml @@ -0,0 +1,71 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-x-forwarded-prefix + namespace: default + annotations: + nginx.ingress.kubernetes.io/x-forwarded-prefix: "x-forwarded-prefix-header-value" + nginx.ingress.kubernetes.io/rewrite-target: "/path" + +spec: + ingressClassName: nginx + rules: + - host: x-forwarded-prefix.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-x-forwarded-prefix-regex + namespace: default + annotations: + nginx.ingress.kubernetes.io/use-regex: "true" + nginx.ingress.kubernetes.io/rewrite-target: "$2" + nginx.ingress.kubernetes.io/x-forwarded-prefix: "$1" + +spec: + ingressClassName: nginx + rules: + - host: x-forwarded-prefix-regex.localhost + http: + paths: + - path: (/something)(/.+) + pathType: ImplementationSpecific + backend: + service: + name: whoami + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-x-forwarded-prefix-three-groups + namespace: default + annotations: + nginx.ingress.kubernetes.io/use-regex: "true" + nginx.ingress.kubernetes.io/rewrite-target: "/$3" + nginx.ingress.kubernetes.io/x-forwarded-prefix: "/$1/$2" + +spec: + ingressClassName: nginx + rules: + - host: x-forwarded-prefix-three-groups.localhost + http: + paths: + - path: /(prefix)/(sub)/(.*) + pathType: ImplementationSpecific + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go index 0a00ab8339..165ab2dc20 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go @@ -1433,28 +1433,41 @@ func (p *Provider) applyCustomHeaders(routerName string, ingressConfig ingressCo return nil } +// Validation identical to ingress-nginx. +var regexPathWithCapture = regexp.MustCompile(`^/?[-._~a-zA-Z0-9/$:]*$`) + func applyRewriteTargetConfiguration(rulePath, routerName string, ingressConfig ingressConfig, rt *dynamic.Router, conf *dynamic.Configuration) { if ingressConfig.RewriteTarget == nil { return } + // Skip rewrite if the path is equal to the target. + if *ingressConfig.RewriteTarget == rulePath { + return + } + rewriteTargetMiddlewareName := routerName + "-rewrite-target" + rewriteTarget := &dynamic.RewriteTarget{ + Replacement: *ingressConfig.RewriteTarget, + } + if ptr.Deref(ingressConfig.UseRegex, false) { - conf.HTTP.Middlewares[rewriteTargetMiddlewareName] = &dynamic.Middleware{ - ReplacePathRegex: &dynamic.ReplacePathRegex{ - Regex: rulePath, - Replacement: *ingressConfig.RewriteTarget, - }, - } - } else { - conf.HTTP.Middlewares[rewriteTargetMiddlewareName] = &dynamic.Middleware{ - ReplacePath: &dynamic.ReplacePath{ - Path: *ingressConfig.RewriteTarget, - }, + rewriteTarget.Regex = rulePath + } + + if ingressConfig.XForwardedPrefix != nil { + if !regexPathWithCapture.MatchString(*ingressConfig.XForwardedPrefix) { + log.Error().Msgf("Invalid x-forwarded-prefix value %q for router %q, skipping x-forwarded-prefix configuration", *ingressConfig.XForwardedPrefix, routerName) + } else { + rewriteTarget.XForwardedPrefix = *ingressConfig.XForwardedPrefix } } + conf.HTTP.Middlewares[rewriteTargetMiddlewareName] = &dynamic.Middleware{ + RewriteTarget: rewriteTarget, + } + rt.Middlewares = append(rt.Middlewares, rewriteTargetMiddlewareName) } diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go index 91f58d2b51..a109e5806f 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go @@ -1157,6 +1157,224 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "X-forwarded-prefix with missing rewrite-target", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-x-forwarded-prefix-no-rewrite-target.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-x-forwarded-prefix-no-rewrite-target-rule-0-path-0": { + Rule: "Host(`x-forwarded-prefix.localhost`) && Path(`/`)", + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-x-forwarded-prefix-no-rewrite-target-rule-0-path-0-retry"}, + Service: "default-ingress-with-x-forwarded-prefix-no-rewrite-target-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-x-forwarded-prefix-no-rewrite-target-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-x-forwarded-prefix-no-rewrite-target-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + ServersTransport: "default-ingress-with-x-forwarded-prefix-no-rewrite-target", + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-x-forwarded-prefix-no-rewrite-target": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, + { + desc: "X-forwarded-prefix", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-x-forwarded-prefix.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-x-forwarded-prefix-rule-0-path-0": { + Rule: "Host(`x-forwarded-prefix.localhost`) && Path(`/`)", + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-x-forwarded-prefix-rule-0-path-0-rewrite-target", "default-ingress-with-x-forwarded-prefix-rule-0-path-0-retry"}, + Service: "default-ingress-with-x-forwarded-prefix-whoami-80", + }, + "default-ingress-with-x-forwarded-prefix-regex-rule-0-path-0": { + Rule: "Host(`x-forwarded-prefix-regex.localhost`) && PathRegexp(`^(/something)(/.+)`)", + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-x-forwarded-prefix-regex-rule-0-path-0-rewrite-target", "default-ingress-with-x-forwarded-prefix-regex-rule-0-path-0-retry"}, + Service: "default-ingress-with-x-forwarded-prefix-regex-whoami-80", + }, + "default-ingress-with-x-forwarded-prefix-three-groups-rule-0-path-0": { + Rule: "Host(`x-forwarded-prefix-three-groups.localhost`) && PathRegexp(`^/(prefix)/(sub)/(.*)`)", + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-x-forwarded-prefix-three-groups-rule-0-path-0-rewrite-target", "default-ingress-with-x-forwarded-prefix-three-groups-rule-0-path-0-retry"}, + Service: "default-ingress-with-x-forwarded-prefix-three-groups-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-x-forwarded-prefix-three-groups-rule-0-path-0-rewrite-target": { + RewriteTarget: &dynamic.RewriteTarget{ + Regex: "/(prefix)/(sub)/(.*)", + Replacement: "/$3", + XForwardedPrefix: "/$1/$2", + }, + }, + "default-ingress-with-x-forwarded-prefix-three-groups-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + "default-ingress-with-x-forwarded-prefix-rule-0-path-0-rewrite-target": { + RewriteTarget: &dynamic.RewriteTarget{ + Replacement: "/path", + XForwardedPrefix: "x-forwarded-prefix-header-value", + }, + }, + "default-ingress-with-x-forwarded-prefix-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + "default-ingress-with-x-forwarded-prefix-regex-rule-0-path-0-rewrite-target": { + RewriteTarget: &dynamic.RewriteTarget{ + Regex: "(/something)(/.+)", + Replacement: "$2", + XForwardedPrefix: "$1", + }, + }, + "default-ingress-with-x-forwarded-prefix-regex-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-x-forwarded-prefix-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + ServersTransport: "default-ingress-with-x-forwarded-prefix", + }, + }, + "default-ingress-with-x-forwarded-prefix-three-groups-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + ServersTransport: "default-ingress-with-x-forwarded-prefix-three-groups", + }, + }, + "default-ingress-with-x-forwarded-prefix-regex-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{ + { + URL: "http://10.10.0.1:80", + }, + { + URL: "http://10.10.0.2:80", + }, + }, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + ServersTransport: "default-ingress-with-x-forwarded-prefix-regex", + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-x-forwarded-prefix": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + "default-ingress-with-x-forwarded-prefix-three-groups": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + "default-ingress-with-x-forwarded-prefix-regex": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{ + DialTimeout: ptypes.Duration(60 * time.Second), + ReadTimeout: ptypes.Duration(60 * time.Second), + WriteTimeout: ptypes.Duration(60 * time.Second), + IdleConnTimeout: ptypes.Duration(60 * time.Second), + }, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "Use Regex", paths: []string{ @@ -1242,7 +1460,7 @@ func TestLoadIngresses(t *testing.T) { }, Middlewares: map[string]*dynamic.Middleware{ "default-ingress-with-rewrite-target-rule-0-path-0-rewrite-target": { - ReplacePathRegex: &dynamic.ReplacePathRegex{ + RewriteTarget: &dynamic.RewriteTarget{ Regex: "/something(/|$)(.*)", Replacement: "/$2", }, @@ -1313,8 +1531,8 @@ func TestLoadIngresses(t *testing.T) { }, Middlewares: map[string]*dynamic.Middleware{ "default-ingress-with-rewrite-target-no-regex-rule-0-path-0-rewrite-target": { - ReplacePath: &dynamic.ReplacePath{ - Path: "/rewritten", + RewriteTarget: &dynamic.RewriteTarget{ + Replacement: "/rewritten", }, }, "default-ingress-with-rewrite-target-no-regex-rule-0-path-0-retry": { diff --git a/pkg/server/middleware/middlewares.go b/pkg/server/middleware/middlewares.go index 9dade702e7..a7bc3516ec 100644 --- a/pkg/server/middleware/middlewares.go +++ b/pkg/server/middleware/middlewares.go @@ -26,6 +26,7 @@ import ( "github.com/traefik/traefik/v3/pkg/middlewares/headers" "github.com/traefik/traefik/v3/pkg/middlewares/inflightreq" "github.com/traefik/traefik/v3/pkg/middlewares/ingressnginx/authtlspasscertificatetoupstream" + "github.com/traefik/traefik/v3/pkg/middlewares/ingressnginx/rewritetarget" "github.com/traefik/traefik/v3/pkg/middlewares/ingressnginx/snippet" "github.com/traefik/traefik/v3/pkg/middlewares/ipallowlist" "github.com/traefik/traefik/v3/pkg/middlewares/ipwhitelist" @@ -340,6 +341,16 @@ func (b *Builder) buildConstructor(ctx context.Context, middlewareName string) ( } } + // RewriteTarget + if config.RewriteTarget != nil { + if middleware != nil { + return nil, badConf + } + middleware = func(next http.Handler) (http.Handler, error) { + return rewritetarget.New(ctx, next, *config.RewriteTarget, middlewareName) + } + } + // Retry if config.Retry != nil { if middleware != nil {