From 121c057b90d64fc07bbc15d2b3b373667492eac5 Mon Sep 17 00:00:00 2001 From: Ben Parli Date: Fri, 30 Jun 2017 16:04:18 -0700 Subject: [PATCH] Custom Error Pages (#1675) * custom error pages --- docs/basics.md | 33 +++++ integration/error_pages_test.go | 69 +++++++++ integration/fixtures/error_pages/error.toml | 27 ++++ integration/fixtures/error_pages/simple.toml | 27 ++++ integration/integration_test.go | 1 + integration/resources/compose/error_pages.yml | 4 + middlewares/error_pages.go | 77 ++++++++++ middlewares/error_pages_test.go | 140 ++++++++++++++++++ server/server.go | 16 ++ types/types.go | 26 ++-- 10 files changed, 411 insertions(+), 9 deletions(-) create mode 100644 integration/error_pages_test.go create mode 100644 integration/fixtures/error_pages/error.toml create mode 100644 integration/fixtures/error_pages/simple.toml create mode 100644 integration/resources/compose/error_pages.yml create mode 100644 middlewares/error_pages.go create mode 100644 middlewares/error_pages_test.go diff --git a/docs/basics.md b/docs/basics.md index 04fd830fd..eedda7b61 100644 --- a/docs/basics.md +++ b/docs/basics.md @@ -396,6 +396,39 @@ Here is an example of backends and servers definition: - `backend2` will forward the traffic to two servers: `http://172.17.0.4:80"` with weight `1` and `http://172.17.0.5:80` with weight `2` using `drr` load-balancing strategy. - a circuit breaker is added on `backend1` using the expression `NetworkErrorRatio() > 0.5`: watch error ratio over 10 second sliding window +## Custom Error pages + +Custom error pages can be returned, in lieu of the default, according to frontend-configured ranges of HTTP Status codes. +In the example below, if a 503 status is returned from the frontend "website", the custom error page at http://2.3.4.5/503.html is returned with the actual status code set in the HTTP header. +Note, the 503.html page itself is not hosted on traefik, but some other infrastructure. + +```toml +[frontends] + [frontends.website] + backend = "website" + [errors] + [error.network] + status = ["500-599"] + backend = "error" + query = "/{status}.html" + [frontends.website.routes.website] + rule = "Host: website.mydomain.com" + +[backends] + [backends.website] + [backends.website.servers.website] + url = "https://1.2.3.4" + [backends.error] + [backends.error.servers.error] + url = "http://2.3.4.5" +``` + +In the above example, the error page rendered was based on the status code. +Instead, the query parameter can also be set to some generic error page like so: `query = "/500s.html"` + +Now the 500s.html error page is returned for the configured code range. +The configured status code ranges are inclusive; that is, in the above example, the 500s.html page will be returned for status codes 500 through, and including, 599. + # Configuration Træfik's configuration has two parts: diff --git a/integration/error_pages_test.go b/integration/error_pages_test.go new file mode 100644 index 000000000..58516c444 --- /dev/null +++ b/integration/error_pages_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "net/http" + "os" + "os/exec" + "time" + + "github.com/containous/traefik/integration/try" + "github.com/go-check/check" + checker "github.com/vdemeester/shakers" +) + +// ErrorPagesSuite test suites (using libcompose) +type ErrorPagesSuite struct{ BaseSuite } + +func (ep *ErrorPagesSuite) SetUpSuite(c *check.C) { + ep.createComposeProject(c, "error_pages") + ep.composeProject.Start(c) +} + +func (ep *ErrorPagesSuite) TestSimpleConfiguration(c *check.C) { + + errorPageHost := ep.composeProject.Container(c, "nginx2").NetworkSettings.IPAddress + backendHost := ep.composeProject.Container(c, "nginx1").NetworkSettings.IPAddress + + file := ep.adaptFile(c, "fixtures/error_pages/simple.toml", struct { + Server1 string + Server2 string + }{backendHost, errorPageHost}) + defer os.Remove(file) + cmd := exec.Command(traefikBinary, "--configFile="+file) + + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + frontendReq, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:80", nil) + c.Assert(err, checker.IsNil) + frontendReq.Host = "test.local" + + err = try.Request(frontendReq, 2*time.Second, try.BodyContains("nginx")) + c.Assert(err, checker.IsNil) +} + +func (ep *ErrorPagesSuite) TestErrorPage(c *check.C) { + + errorPageHost := ep.composeProject.Container(c, "nginx2").NetworkSettings.IPAddress + backendHost := ep.composeProject.Container(c, "nginx1").NetworkSettings.IPAddress + + //error.toml contains a mis-configuration of the backend host + file := ep.adaptFile(c, "fixtures/error_pages/error.toml", struct { + Server1 string + Server2 string + }{backendHost, errorPageHost}) + defer os.Remove(file) + cmd := exec.Command(traefikBinary, "--configFile="+file) + + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + frontendReq, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:80", nil) + c.Assert(err, checker.IsNil) + frontendReq.Host = "test.local" + + err = try.Request(frontendReq, 2*time.Second, try.BodyContains("An error occurred.")) + c.Assert(err, checker.IsNil) +} diff --git a/integration/fixtures/error_pages/error.toml b/integration/fixtures/error_pages/error.toml new file mode 100644 index 000000000..09f63986e --- /dev/null +++ b/integration/fixtures/error_pages/error.toml @@ -0,0 +1,27 @@ +defaultEntryPoints = ["http"] + +logLevel = "DEBUG" + +[entryPoints] + [entryPoints.http] + address = ":80" + +[file] +[backends] + [backends.backend1] + [backends.backend1.servers.server1] + url = "http://{{.Server1}}:8989474" + [backends.error] + [backends.error.servers.error] + url = "http://{{.Server2}}:80" +[frontends] + [frontends.frontend1] + passHostHeader = true + backend = "backend1" + [frontends.frontend1.routes.test_1] + rule = "Host:test.local" + [frontends.frontend1.errors] + [frontends.frontend1.errors.networks] + status = ["500-502", "503-599"] + backend = "error" + query = "/50x.html" diff --git a/integration/fixtures/error_pages/simple.toml b/integration/fixtures/error_pages/simple.toml new file mode 100644 index 000000000..4f4e907fd --- /dev/null +++ b/integration/fixtures/error_pages/simple.toml @@ -0,0 +1,27 @@ +defaultEntryPoints = ["http"] + +logLevel = "DEBUG" + +[entryPoints] + [entryPoints.http] + address = ":80" + +[file] +[backends] + [backends.backend1] + [backends.backend1.servers.server1] + url = "http://{{.Server1}}:80" + [backends.error] + [backends.error.servers.error] + url = "http://{{.Server2}}:80" +[frontends] + [frontends.frontend1] + passHostHeader = true + backend = "backend1" + [frontends.frontend1.routes.test_1] + rule = "Host:test.local" + [frontends.frontend1.errors] + [frontends.frontend1.errors.networks] + status = ["500-502", "503-599"] + backend = "error" + query = "/50x.html" diff --git a/integration/integration_test.go b/integration/integration_test.go index d39bb53c4..9bf02ce04 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -38,6 +38,7 @@ func init() { check.Suite(&EurekaSuite{}) check.Suite(&AcmeSuite{}) check.Suite(&DynamoDBSuite{}) + check.Suite(&ErrorPagesSuite{}) } var traefikBinary = "../dist/traefik" diff --git a/integration/resources/compose/error_pages.yml b/integration/resources/compose/error_pages.yml new file mode 100644 index 000000000..964b87b7f --- /dev/null +++ b/integration/resources/compose/error_pages.yml @@ -0,0 +1,4 @@ +nginx1: + image: nginx:alpine +nginx2: + image: nginx:alpine diff --git a/middlewares/error_pages.go b/middlewares/error_pages.go new file mode 100644 index 000000000..3ef49f11d --- /dev/null +++ b/middlewares/error_pages.go @@ -0,0 +1,77 @@ +package middlewares + +import ( + "net/http" + "strconv" + "strings" + + "github.com/containous/traefik/log" + "github.com/containous/traefik/types" + "github.com/vulcand/oxy/forward" + "github.com/vulcand/oxy/utils" +) + +//ErrorPagesHandler is a middleware that provides the custom error pages +type ErrorPagesHandler struct { + HTTPCodeRanges [][2]int + BackendURL string + errorPageForwarder *forward.Forwarder +} + +//NewErrorPagesHandler initializes the utils.ErrorHandler for the custom error pages +func NewErrorPagesHandler(errorPage types.ErrorPage, backendURL string) (*ErrorPagesHandler, error) { + fwd, err := forward.New() + if err != nil { + return nil, err + } + + //Break out the http status code ranges into a low int and high int + //for ease of use at runtime + var blocks [][2]int + for _, block := range errorPage.Status { + codes := strings.Split(block, "-") + //if only a single HTTP code was configured, assume the best and create the correct configuration on the user's behalf + if len(codes) == 1 { + codes = append(codes, codes[0]) + } + lowCode, err := strconv.Atoi(codes[0]) + if err != nil { + return nil, err + } + highCode, err := strconv.Atoi(codes[1]) + if err != nil { + return nil, err + } + blocks = append(blocks, [2]int{lowCode, highCode}) + } + return &ErrorPagesHandler{ + HTTPCodeRanges: blocks, + BackendURL: backendURL + errorPage.Query, + errorPageForwarder: fwd}, + nil +} + +func (ep *ErrorPagesHandler) ServeHTTP(w http.ResponseWriter, req *http.Request, next http.HandlerFunc) { + recorder := newRetryResponseRecorder() + recorder.responseWriter = w + next.ServeHTTP(recorder, req) + + w.WriteHeader(recorder.Code) + //check the recorder code against the configured http status code ranges + for _, block := range ep.HTTPCodeRanges { + if recorder.Code >= block[0] && recorder.Code <= block[1] { + log.Errorf("Caught HTTP Status Code %d, returning error page", recorder.Code) + finalURL := strings.Replace(ep.BackendURL, "{status}", strconv.Itoa(recorder.Code), -1) + if newReq, err := http.NewRequest(http.MethodGet, finalURL, nil); err != nil { + w.Write([]byte(http.StatusText(recorder.Code))) + } else { + ep.errorPageForwarder.ServeHTTP(w, newReq) + } + return + } + } + + //did not catch a configured status code so proceed with the request + utils.CopyHeaders(w.Header(), recorder.Header()) + w.Write(recorder.Body.Bytes()) +} diff --git a/middlewares/error_pages_test.go b/middlewares/error_pages_test.go new file mode 100644 index 000000000..e5027a66b --- /dev/null +++ b/middlewares/error_pages_test.go @@ -0,0 +1,140 @@ +package middlewares + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/codegangsta/negroni" + "github.com/containous/traefik/types" + "github.com/stretchr/testify/assert" +) + +func TestErrorPage(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "Test Server") + })) + defer ts.Close() + + testErrorPage := &types.ErrorPage{Backend: "error", Query: "/test", Status: []string{"500-501", "503-599"}} + testHandler, err := NewErrorPagesHandler(*testErrorPage, ts.URL) + + assert.Equal(t, nil, err, "Should be no error") + assert.Equal(t, testHandler.BackendURL, ts.URL+"/test", "Should be equal") + + recorder := httptest.NewRecorder() + req, err := http.NewRequest("GET", ts.URL+"/test", nil) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "traefik") + }) + n := negroni.New() + n.Use(testHandler) + n.UseHandler(handler) + + n.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusOK, recorder.Code, "HTTP statusOK") + assert.Contains(t, recorder.Body.String(), "traefik") + + handler500 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + fmt.Fprintln(w, "oops") + }) + recorder500 := httptest.NewRecorder() + n500 := negroni.New() + n500.Use(testHandler) + n500.UseHandler(handler500) + + n500.ServeHTTP(recorder500, req) + + assert.Equal(t, http.StatusInternalServerError, recorder500.Code, "HTTP status Internal Server Error") + assert.Contains(t, recorder500.Body.String(), "Test Server") + assert.NotContains(t, recorder500.Body.String(), "oops", "Should not return the oops page") + + handler502 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(502) + fmt.Fprintln(w, "oops") + }) + recorder502 := httptest.NewRecorder() + n502 := negroni.New() + n502.Use(testHandler) + n502.UseHandler(handler502) + + n502.ServeHTTP(recorder502, req) + + assert.Equal(t, http.StatusBadGateway, recorder502.Code, "HTTP status Bad Gateway") + assert.Contains(t, recorder502.Body.String(), "oops") + assert.NotContains(t, recorder502.Body.String(), "Test Server", "Should return the oops page since we have not configured the 502 code") + +} + +func TestErrorPageQuery(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RequestURI() == "/"+strconv.Itoa(503) { + fmt.Fprintln(w, "503 Test Server") + } else { + fmt.Fprintln(w, "Failed") + } + + })) + defer ts.Close() + + testErrorPage := &types.ErrorPage{Backend: "error", Query: "/{status}", Status: []string{"503-503"}} + testHandler, err := NewErrorPagesHandler(*testErrorPage, ts.URL) + assert.Equal(t, nil, err, "Should be no error") + assert.Equal(t, testHandler.BackendURL, ts.URL+"/{status}", "Should be equal") + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(503) + fmt.Fprintln(w, "oops") + }) + recorder := httptest.NewRecorder() + req, err := http.NewRequest("GET", ts.URL+"/test", nil) + n := negroni.New() + n.Use(testHandler) + n.UseHandler(handler) + + n.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusServiceUnavailable, recorder.Code, "HTTP status Service Unavailable") + assert.Contains(t, recorder.Body.String(), "503 Test Server") + assert.NotContains(t, recorder.Body.String(), "oops", "Should not return the oops page") + +} + +func TestErrorPageSingleCode(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RequestURI() == "/"+strconv.Itoa(503) { + fmt.Fprintln(w, "503 Test Server") + } else { + fmt.Fprintln(w, "Failed") + } + + })) + defer ts.Close() + + testErrorPage := &types.ErrorPage{Backend: "error", Query: "/{status}", Status: []string{"503"}} + testHandler, err := NewErrorPagesHandler(*testErrorPage, ts.URL) + assert.Equal(t, nil, err, "Should be no error") + assert.Equal(t, testHandler.BackendURL, ts.URL+"/{status}", "Should be equal") + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(503) + fmt.Fprintln(w, "oops") + }) + recorder := httptest.NewRecorder() + req, err := http.NewRequest("GET", ts.URL+"/test", nil) + n := negroni.New() + n.Use(testHandler) + n.UseHandler(handler) + + n.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusServiceUnavailable, recorder.Code, "HTTP status Service Unavailable") + assert.Contains(t, recorder.Body.String(), "503 Test Server") + assert.NotContains(t, recorder.Body.String(), "oops", "Should not return the oops page") + +} diff --git a/server/server.go b/server/server.go index 1c02958bb..955b3b28a 100644 --- a/server/server.go +++ b/server/server.go @@ -772,6 +772,22 @@ func (server *Server) loadConfig(configurations configs, globalConfiguration Glo backendsHealthcheck[frontend.Backend] = healthcheck.NewBackendHealthCheck(*hcOpts) } } + + if len(frontend.Errors) > 0 { + for _, errorPage := range frontend.Errors { + if configuration.Backends[errorPage.Backend] != nil && configuration.Backends[errorPage.Backend].Servers["error"].URL != "" { + errorPageHandler, err := middlewares.NewErrorPagesHandler(errorPage, configuration.Backends[errorPage.Backend].Servers["error"].URL) + if err != nil { + log.Errorf("Error creating custom error page middleware, %v", err) + } else { + negroni.Use(errorPageHandler) + } + } else { + log.Errorf("Error Page is configured for Frontend %s, but either Backend %s is not set or Backend URL is missing", frontendName, errorPage.Backend) + } + } + } + maxConns := configuration.Backends[frontend.Backend].MaxConn if maxConns != nil && maxConns.Amount != 0 { extractFunc, err := utils.NewExtractor(maxConns.ExtractorFunc) diff --git a/types/types.go b/types/types.go index fe2ba7c6e..f971f7a52 100644 --- a/types/types.go +++ b/types/types.go @@ -54,6 +54,13 @@ type Route struct { Rule string `json:"rule,omitempty"` } +//ErrorPage holds custom error page configuration +type ErrorPage struct { + Status []string `json:"status,omitempty"` + Backend string `json:"backend,omitempty"` + Query string `json:"query,omitempty"` +} + // Headers holds the custom header configuration type Headers struct { CustomRequestHeaders map[string]string `json:"customRequestHeaders,omitempty"` @@ -108,15 +115,16 @@ func (h Headers) HasSecureHeadersDefined() bool { // Frontend holds frontend configuration. type Frontend struct { - EntryPoints []string `json:"entryPoints,omitempty"` - Backend string `json:"backend,omitempty"` - Routes map[string]Route `json:"routes,omitempty"` - PassHostHeader bool `json:"passHostHeader,omitempty"` - PassTLSCert bool `json:"passTLSCert,omitempty"` - Priority int `json:"priority"` - BasicAuth []string `json:"basicAuth"` - WhitelistSourceRange []string `json:"whitelistSourceRange,omitempty"` - Headers Headers `json:"headers,omitempty"` + EntryPoints []string `json:"entryPoints,omitempty"` + Backend string `json:"backend,omitempty"` + Routes map[string]Route `json:"routes,omitempty"` + PassHostHeader bool `json:"passHostHeader,omitempty"` + PassTLSCert bool `json:"passTLSCert,omitempty"` + Priority int `json:"priority"` + BasicAuth []string `json:"basicAuth"` + WhitelistSourceRange []string `json:"whitelistSourceRange,omitempty"` + Headers Headers `json:"headers,omitempty"` + Errors map[string]ErrorPage `json:"errors,omitempty"` } // LoadBalancerMethod holds the method of load balancing to use.