diff --git a/docs/content/middlewares/http/errorpages.md b/docs/content/middlewares/http/errorpages.md index c4569133e..1a4f11c41 100644 --- a/docs/content/middlewares/http/errorpages.md +++ b/docs/content/middlewares/http/errorpages.md @@ -102,6 +102,19 @@ The status code ranges are inclusive (`505-599` will trigger with every code bet The comma-separated syntax is only available for label-based providers. The examples above demonstrate which syntax is appropriate for each provider. +### `statusRewrites` + +An optional mapping of status codes to be rewritten. For example, if a service returns a 418, you might want to rewrite it to a 404. +You can map individual status codes or even ranges to a different status code. The syntax for ranges follows the same rules as the `status` option. + +Here is an example: + +```yml +statusRewrites: + "500-503": 500 + "418": 404 +``` + ### `service` The service that will serve the new requested error page. @@ -123,7 +136,8 @@ There are multiple variables that can be placed in the `query` option to insert The table below lists all the available variables and their associated values. -| Variable | Value | -|------------|--------------------------------------------------------------------| -| `{status}` | The response status code. | -| `{url}` | The [escaped](https://pkg.go.dev/net/url#QueryEscape) request URL. | +| Variable | Value | +|--------------------|--------------------------------------------------------------------------------------------| +| `{status}` | The response status code. It may be rewritten when using the `statusRewrites` option. | +| `{originalStatus}` | The original response status code, if it has been modified by the `statusRewrites` option. | +| `{url}` | The [escaped](https://pkg.go.dev/net/url#QueryEscape) request URL. | diff --git a/docs/content/reference/dynamic-configuration/docker-labels.yml b/docs/content/reference/dynamic-configuration/docker-labels.yml index bf4308237..a6005b91d 100644 --- a/docs/content/reference/dynamic-configuration/docker-labels.yml +++ b/docs/content/reference/dynamic-configuration/docker-labels.yml @@ -33,6 +33,8 @@ - "traefik.http.middlewares.middleware09.errors.query=foobar" - "traefik.http.middlewares.middleware09.errors.service=foobar" - "traefik.http.middlewares.middleware09.errors.status=foobar, foobar" +- "traefik.http.middlewares.middleware09.errors.statusrewrites.name0=42" +- "traefik.http.middlewares.middleware09.errors.statusrewrites.name1=42" - "traefik.http.middlewares.middleware10.forwardauth.addauthcookiestoresponse=foobar, foobar" - "traefik.http.middlewares.middleware10.forwardauth.address=foobar" - "traefik.http.middlewares.middleware10.forwardauth.authrequestheaders=foobar, foobar" diff --git a/docs/content/reference/dynamic-configuration/file.toml b/docs/content/reference/dynamic-configuration/file.toml index a951a0e70..4b3a500f5 100644 --- a/docs/content/reference/dynamic-configuration/file.toml +++ b/docs/content/reference/dynamic-configuration/file.toml @@ -173,6 +173,9 @@ status = ["foobar", "foobar"] service = "foobar" query = "foobar" + [http.middlewares.Middleware09.errors.statusRewrites] + name0 = 42 + name1 = 42 [http.middlewares.Middleware10] [http.middlewares.Middleware10.forwardAuth] address = "foobar" diff --git a/docs/content/reference/dynamic-configuration/file.yaml b/docs/content/reference/dynamic-configuration/file.yaml index 7813d35d9..3894c86d8 100644 --- a/docs/content/reference/dynamic-configuration/file.yaml +++ b/docs/content/reference/dynamic-configuration/file.yaml @@ -186,6 +186,9 @@ http: status: - foobar - foobar + statusRewrites: + name0: 42 + name1: 42 service: foobar query: foobar Middleware10: diff --git a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml index c14f6d05b..12d430b5f 100644 --- a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml +++ b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml @@ -1003,6 +1003,8 @@ spec: description: |- Query defines the URL for the error page (hosted by service). The {status} variable can be used in order to insert the status code in the URL. + The {originalStatus} variable can be used in order to insert the upstream status code in the URL. + The {url} variable can be used in order to insert the escaped request URL. type: string service: description: |- @@ -1199,6 +1201,13 @@ spec: items: type: string type: array + statusRewrites: + additionalProperties: + type: integer + description: |- + StatusRewrites defines a mapping of status codes that should be returned instead of the original error status codes. + For example: "418": 404 or "410-418": 404 + type: object type: object forwardAuth: description: |- diff --git a/docs/content/reference/dynamic-configuration/kv-ref.md b/docs/content/reference/dynamic-configuration/kv-ref.md index c2cea9b1b..cb35b2b9c 100644 --- a/docs/content/reference/dynamic-configuration/kv-ref.md +++ b/docs/content/reference/dynamic-configuration/kv-ref.md @@ -40,6 +40,8 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/http/middlewares/Middleware09/errors/service` | `foobar` | | `traefik/http/middlewares/Middleware09/errors/status/0` | `foobar` | | `traefik/http/middlewares/Middleware09/errors/status/1` | `foobar` | +| `traefik/http/middlewares/Middleware09/errors/statusRewrites/name0` | `42` | +| `traefik/http/middlewares/Middleware09/errors/statusRewrites/name1` | `42` | | `traefik/http/middlewares/Middleware10/forwardAuth/addAuthCookiesToResponse/0` | `foobar` | | `traefik/http/middlewares/Middleware10/forwardAuth/addAuthCookiesToResponse/1` | `foobar` | | `traefik/http/middlewares/Middleware10/forwardAuth/address` | `foobar` | diff --git a/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml b/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml index 340c2718d..6b00592e8 100644 --- a/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml +++ b/docs/content/reference/dynamic-configuration/traefik.io_middlewares.yaml @@ -261,6 +261,8 @@ spec: description: |- Query defines the URL for the error page (hosted by service). The {status} variable can be used in order to insert the status code in the URL. + The {originalStatus} variable can be used in order to insert the upstream status code in the URL. + The {url} variable can be used in order to insert the escaped request URL. type: string service: description: |- @@ -457,6 +459,13 @@ spec: items: type: string type: array + statusRewrites: + additionalProperties: + type: integer + description: |- + StatusRewrites defines a mapping of status codes that should be returned instead of the original error status codes. + For example: "418": 404 or "410-418": 404 + type: object type: object forwardAuth: description: |- diff --git a/integration/error_pages_test.go b/integration/error_pages_test.go index 8fd77ac58..f895216e0 100644 --- a/integration/error_pages_test.go +++ b/integration/error_pages_test.go @@ -69,6 +69,30 @@ func (s *ErrorPagesSuite) TestErrorPage() { require.NoError(s.T(), err) } +func (s *ErrorPagesSuite) TestStatusRewrites() { + // The `statusRewrites.toml` file contains a misconfigured backend host and some status code rewrites. + file := s.adaptFile("fixtures/error_pages/statusRewrites.toml", struct { + Server1 string + Server2 string + }{s.BackendIP, s.ErrorPageIP}) + + s.traefikCmd(withConfigFile(file)) + + frontendReq, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8080", nil) + require.NoError(s.T(), err) + frontendReq.Host = "test502.local" + + err = try.Request(frontendReq, 2*time.Second, try.BodyContains("An error occurred."), try.StatusCodeIs(404)) + require.NoError(s.T(), err) + + frontendReq, err = http.NewRequest(http.MethodGet, "http://127.0.0.1:8080", nil) + require.NoError(s.T(), err) + frontendReq.Host = "test418.local" + + err = try.Request(frontendReq, 2*time.Second, try.BodyContains("An error occurred."), try.StatusCodeIs(400)) + require.NoError(s.T(), err) +} + func (s *ErrorPagesSuite) TestErrorPageFlush() { srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Transfer-Encoding", "chunked") diff --git a/integration/fixtures/error_pages/statusRewrites.toml b/integration/fixtures/error_pages/statusRewrites.toml new file mode 100644 index 000000000..a127aff60 --- /dev/null +++ b/integration/fixtures/error_pages/statusRewrites.toml @@ -0,0 +1,45 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.web] + address = ":8080" + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + [http.routers.router1] + rule = "Host(`test502.local`)" + service = "service1" + middlewares = ["error"] + [http.routers.router2] + rule = "Host(`test418.local`)" + service = "noop@internal" + middlewares = ["error"] + +[http.middlewares] + [http.middlewares.error.errors] + status = ["500-502", "503-599", "418"] + service = "error" + query = "/50x.html" + [http.middlewares.error.errors.statusRewrites] + "418" = 400 + "500-502" = 404 + +[http.services] + [http.services.service1.loadBalancer] + passHostHeader = true + [[http.services.service1.loadBalancer.servers]] + url = "http://{{.Server1}}:8989474" + + [http.services.error.loadBalancer] + [[http.services.error.loadBalancer.servers]] + url = "http://{{.Server2}}:80" diff --git a/integration/fixtures/k8s/01-traefik-crd.yml b/integration/fixtures/k8s/01-traefik-crd.yml index c14f6d05b..12d430b5f 100644 --- a/integration/fixtures/k8s/01-traefik-crd.yml +++ b/integration/fixtures/k8s/01-traefik-crd.yml @@ -1003,6 +1003,8 @@ spec: description: |- Query defines the URL for the error page (hosted by service). The {status} variable can be used in order to insert the status code in the URL. + The {originalStatus} variable can be used in order to insert the upstream status code in the URL. + The {url} variable can be used in order to insert the escaped request URL. type: string service: description: |- @@ -1199,6 +1201,13 @@ spec: items: type: string type: array + statusRewrites: + additionalProperties: + type: integer + description: |- + StatusRewrites defines a mapping of status codes that should be returned instead of the original error status codes. + For example: "418": 404 or "410-418": 404 + type: object type: object forwardAuth: description: |- diff --git a/pkg/config/dynamic/middlewares.go b/pkg/config/dynamic/middlewares.go index 4d1055b11..0eed91a19 100644 --- a/pkg/config/dynamic/middlewares.go +++ b/pkg/config/dynamic/middlewares.go @@ -222,10 +222,15 @@ type ErrorPage struct { // as ranges by separating two codes with a dash (500-599), // or a combination of the two (404,418,500-599). Status []string `json:"status,omitempty" toml:"status,omitempty" yaml:"status,omitempty" export:"true"` + // StatusRewrites defines a mapping of status codes that should be returned instead of the original error status codes. + // For example: "418": 404 or "410-418": 404 + StatusRewrites map[string]int `json:"statusRewrites,omitempty" toml:"statusRewrites,omitempty" yaml:"statusRewrites,omitempty" export:"true"` // Service defines the name of the service that will serve the error page. Service string `json:"service,omitempty" toml:"service,omitempty" yaml:"service,omitempty" export:"true"` // Query defines the URL for the error page (hosted by service). // The {status} variable can be used in order to insert the status code in the URL. + // The {originalStatus} variable can be used in order to insert the upstream status code in the URL. + // The {url} variable can be used in order to insert the escaped request URL. Query string `json:"query,omitempty" toml:"query,omitempty" yaml:"query,omitempty" export:"true"` } diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index 768b2fc6e..2f0e8dd4d 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -313,6 +313,13 @@ func (in *ErrorPage) DeepCopyInto(out *ErrorPage) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.StatusRewrites != nil { + in, out := &in.StatusRewrites, &out.StatusRewrites + *out = make(map[string]int, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } return } diff --git a/pkg/middlewares/customerrors/custom_errors.go b/pkg/middlewares/customerrors/custom_errors.go index c5a1c6f22..3cd866a74 100644 --- a/pkg/middlewares/customerrors/custom_errors.go +++ b/pkg/middlewares/customerrors/custom_errors.go @@ -37,6 +37,12 @@ type customErrors struct { backendHandler http.Handler httpCodeRanges types.HTTPCodeRanges backendQuery string + statusRewrites []statusRewrite +} + +type statusRewrite struct { + fromCodes types.HTTPCodeRanges + toCode int } // New creates a new custom error pages middleware. @@ -53,12 +59,27 @@ func New(ctx context.Context, next http.Handler, config dynamic.ErrorPage, servi return nil, err } + // Parse StatusRewrites + statusRewrites := make([]statusRewrite, 0, len(config.StatusRewrites)) + for k, v := range config.StatusRewrites { + ranges, err := types.NewHTTPCodeRanges([]string{k}) + if err != nil { + return nil, err + } + + statusRewrites = append(statusRewrites, statusRewrite{ + fromCodes: ranges, + toCode: v, + }) + } + return &customErrors{ name: name, next: next, backendHandler: backend, httpCodeRanges: httpCodeRanges, backendQuery: config.Query, + statusRewrites: statusRewrites, }, nil } @@ -84,12 +105,28 @@ func (c *customErrors) ServeHTTP(rw http.ResponseWriter, req *http.Request) { // check the recorder code against the configured http status code ranges code := catcher.getCode() - logger.Debug().Msgf("Caught HTTP Status Code %d, returning error page", code) + + originalCode := code + + // Check if we need to rewrite the status code + for _, rsc := range c.statusRewrites { + if rsc.fromCodes.Contains(code) { + code = rsc.toCode + break + } + } + + if code != originalCode { + logger.Debug().Msgf("Caught HTTP Status Code %d (rewritten to %d), returning error page", originalCode, code) + } else { + logger.Debug().Msgf("Caught HTTP Status Code %d, returning error page", code) + } var query string if len(c.backendQuery) > 0 { query = "/" + strings.TrimPrefix(c.backendQuery, "/") query = strings.ReplaceAll(query, "{status}", strconv.Itoa(code)) + query = strings.ReplaceAll(query, "{originalStatus}", strconv.Itoa(originalCode)) query = strings.ReplaceAll(query, "{url}", url.QueryEscape(req.URL.String())) } diff --git a/pkg/provider/kubernetes/crd/fixtures/with_error_page.yml b/pkg/provider/kubernetes/crd/fixtures/with_error_page.yml index e352d596b..f7e6bb167 100644 --- a/pkg/provider/kubernetes/crd/fixtures/with_error_page.yml +++ b/pkg/provider/kubernetes/crd/fixtures/with_error_page.yml @@ -9,6 +9,8 @@ spec: status: - "404" - "500" + statusRewrites: + 404: 200 query: query service: name: whoami diff --git a/pkg/provider/kubernetes/crd/kubernetes.go b/pkg/provider/kubernetes/crd/kubernetes.go index a6e9481b8..5e435346b 100644 --- a/pkg/provider/kubernetes/crd/kubernetes.go +++ b/pkg/provider/kubernetes/crd/kubernetes.go @@ -737,8 +737,9 @@ func (p *Provider) createErrorPageMiddleware(client Client, namespace string, er } errorPageMiddleware := &dynamic.ErrorPage{ - Status: errorPage.Status, - Query: errorPage.Query, + Status: errorPage.Status, + StatusRewrites: errorPage.StatusRewrites, + Query: errorPage.Query, } cb := configBuilder{ diff --git a/pkg/provider/kubernetes/crd/kubernetes_test.go b/pkg/provider/kubernetes/crd/kubernetes_test.go index 2b96ac965..3d88287ea 100644 --- a/pkg/provider/kubernetes/crd/kubernetes_test.go +++ b/pkg/provider/kubernetes/crd/kubernetes_test.go @@ -4160,7 +4160,10 @@ func TestLoadIngressRoutes(t *testing.T) { Middlewares: map[string]*dynamic.Middleware{ "default-errorpage": { Errors: &dynamic.ErrorPage{ - Status: []string{"404", "500"}, + Status: []string{"404", "500"}, + StatusRewrites: map[string]int{ + "404": 200, + }, Service: "default-errorpage-errorpage-service", Query: "query", }, diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go index 51d50b187..891c78aa2 100644 --- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go +++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/middleware.go @@ -68,11 +68,16 @@ type ErrorPage struct { // as ranges by separating two codes with a dash (500-599), // or a combination of the two (404,418,500-599). Status []string `json:"status,omitempty"` + // StatusRewrites defines a mapping of status codes that should be returned instead of the original error status codes. + // For example: "418": 404 or "410-418": 404 + StatusRewrites map[string]int `json:"statusRewrites,omitempty"` // Service defines the reference to a Kubernetes Service that will serve the error page. // More info: https://doc.traefik.io/traefik/v3.3/middlewares/http/errorpages/#service Service Service `json:"service,omitempty"` // Query defines the URL for the error page (hosted by service). // The {status} variable can be used in order to insert the status code in the URL. + // The {originalStatus} variable can be used in order to insert the upstream status code in the URL. + // The {url} variable can be used in order to insert the escaped request URL. Query string `json:"query,omitempty"` } diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go index 23a866587..ae83e6173 100644 --- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/zz_generated.deepcopy.go @@ -229,6 +229,13 @@ func (in *ErrorPage) DeepCopyInto(out *ErrorPage) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.StatusRewrites != nil { + in, out := &in.StatusRewrites, &out.StatusRewrites + *out = make(map[string]int, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } in.Service.DeepCopyInto(&out.Service) return }