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 {