We’ll be back soon!
+Sorry for the inconvenience but we’re performing some maintenance at the moment. We’ll be back online shortly!
+— The Team
+diff --git a/README.md b/README.md index 2e3e5a1..7bf361a 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,10 @@ Optional, can be turned on with `--mgmt.enabled`. Exposes 2 endpoints on `mgmt.l _see also [examples/metrics](https://github.com/umputun/reproxy/examples/metrics)_ +## Errors reporting + +Reproxy returns 502 (Bad Gateway) error in case if request doesn't match to any provided routes and assets. In case if some unexpected, internal error happened it returns 500. By default reproxy renders the simplest text version of the error - "Server error". Setting `--error.enabled` turns on the default html error message and with `--error.template` user may set any custom html template file for the error rendering. The template has two vars: `{{.ErrCode}}` and `{{.ErrMessage}}`. For example this template `oh my! {{.ErrCode}} - {{.ErrMessage}}` will be rendered to `oh my! 502 - Bad Gateway` + ## All Application Options ``` @@ -227,6 +231,10 @@ mgmt: --mgmt.enabled enable management API [$MGMT_ENABLED] --mgmt.listen= listen on host:port (default: 0.0.0.0:8081) [$MGMT_LISTEN] +error: + --error.enabled enable html errors reporting [$ERROR_ENABLED] + --error.template= error message template file [$ERROR_TEMPLATE] + Help Options: -h, --help Show this help message diff --git a/app/main.go b/app/main.go index 50a0620..f87f3e9 100644 --- a/app/main.go +++ b/app/main.go @@ -91,6 +91,11 @@ var opts struct { Listen string `long:"listen" env:"LISTEN" default:"0.0.0.0:8081" description:"listen on host:port"` } `group:"mgmt" namespace:"mgmt" env-namespace:"MGMT"` + ErrorReport struct { + Enabled bool `long:"enabled" env:"ENABLED" description:"enable html errors reporting"` + Template string `long:"template" env:"TEMPLATE" description:"error message template file"` + } `group:"error" namespace:"error" env-namespace:"ERROR"` + Signature bool `long:"signature" env:"SIGNATURE" description:"enable reproxy signature headers"` Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"` } @@ -184,6 +189,11 @@ func run() error { return fmt.Errorf("failed to make cache control: %w", err) } + errReporter, err := makeErrorReporter() + if err != nil { + return fmt.Errorf("failed to make error reporter: %w", err) + } + px := &proxy.Http{ Version: revision, Matcher: svc, @@ -209,7 +219,8 @@ func run() error { ExpectContinue: opts.Timeouts.ExpectContinue, ResponseHeader: opts.Timeouts.ResponseHeader, }, - Metrics: metrics, + Metrics: metrics, + Reporter: errReporter, } err = px.Run(ctx) @@ -286,6 +297,20 @@ func makeSSLConfig() (config proxy.SSLConfig, err error) { return config, err } +func makeErrorReporter() (proxy.Reporter, error) { + result := &proxy.ErrorReporter{ + Nice: opts.ErrorReport.Enabled, + } + if opts.ErrorReport.Template != "" { + data, err := ioutil.ReadFile(opts.ErrorReport.Template) + if err != nil { + return nil, fmt.Errorf("failed to load error html template from %s, %w", opts.ErrorReport.Template, err) + } + result.Template = string(data) + } + return result, nil +} + func makeAccessLogWriter() (accessLog io.WriteCloser) { if !opts.Logger.Enabled { return nopWriteCloser{ioutil.Discard} diff --git a/app/main_test.go b/app/main_test.go index 9e1d0c1..2370843 100644 --- a/app/main_test.go +++ b/app/main_test.go @@ -25,7 +25,9 @@ func Test_Main(t *testing.T) { "--static.rule=*,/svc2/(.*), https://echo.umputun.com/$1,https://feedmaster.umputun.com/ping", "--file.enabled", "--file.name=discovery/provider/testdata/config.yml", "--dbg", "--logger.enabled", "--logger.stdout", "--logger.file=/tmp/reproxy.log", - "--listen=127.0.0.1:" + strconv.Itoa(port), "--signature"} + "--listen=127.0.0.1:" + strconv.Itoa(port), "--signature", + "--error.enabled", "--error.template=proxy/testdata/errtmpl.html", + } defer os.Remove("/tmp/reproxy.log") done := make(chan struct{}) go func() { @@ -84,6 +86,9 @@ func Test_Main(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusBadGateway, resp.StatusCode) + body, err := ioutil.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Equal(t, "oh my! 502 - Bad Gateway", string(body)) } } diff --git a/app/proxy/cache_control.go b/app/proxy/cache_control.go index b91c3f4..8e6ae2c 100644 --- a/app/proxy/cache_control.go +++ b/app/proxy/cache_control.go @@ -44,6 +44,7 @@ func (c *CacheControl) Middleware(next http.Handler) http.Handler { if len(c.maxAges) == 0 && c.defaultMaxAge > 0 { setMaxAgeHeader(c.defaultMaxAge, w) next.ServeHTTP(w, r) + return } ext := path.Ext(r.URL.Path) // the extension ext should begin with a leading dot, as in ".html" diff --git a/app/proxy/error_reporter.go b/app/proxy/error_reporter.go new file mode 100644 index 0000000..2437491 --- /dev/null +++ b/app/proxy/error_reporter.go @@ -0,0 +1,74 @@ +package proxy + +import ( + "html/template" + "log" + "net/http" + "sync" +) + +// ErrorReporter formats error with a given template +// Supports go-style template with {{.ErrMessage}} and {{.ErrCode}} +type ErrorReporter struct { + Template string + Nice bool + + tmpl struct { + *template.Template + sync.Once + } +} + +// Report formats and sends error to ResponseWriter +func (em *ErrorReporter) Report(w http.ResponseWriter, code int) { + em.tmpl.Do(func() { + if em.Template == "" { + em.Template = errDefaultTemplate + } + tp, err := template.New("errmsg").Parse(em.Template) + if err != nil { + log.Printf("[WARN] failed to parse error template, %v", err) + return + } + em.tmpl.Template = tp + }) + + if em.tmpl.Template == nil || !em.Nice { + http.Error(w, "Server error", code) + return + } + + data := struct { + ErrMessage string + ErrCode int + }{ + ErrMessage: http.StatusText(code), + ErrCode: code, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(code) + _ = em.tmpl.Execute(w, &data) +} + +var errDefaultTemplate = ` + +
Sorry for the inconvenience but we’re performing some maintenance at the moment. We’ll be back online shortly!
+— The Team
+Sorry for the inconvenience") +} + +func TestErrorReporter_BadTemplate(t *testing.T) { + er := ErrorReporter{Nice: true, Template: "xxx {{."} + wr := httptest.NewRecorder() + er.Report(wr, 502) + assert.Equal(t, 502, wr.Code) + assert.Equal(t, "Server error\n", wr.Body.String()) +} diff --git a/app/proxy/proxy.go b/app/proxy/proxy.go index 3d1bd34..b465b89 100644 --- a/app/proxy/proxy.go +++ b/app/proxy/proxy.go @@ -38,6 +38,7 @@ type Http struct { // nolint golint Timeouts Timeouts CacheControl MiddlewareProvider Metrics MiddlewareProvider + Reporter Reporter } // Matcher source info (server and route) to the destination url @@ -53,6 +54,11 @@ type MiddlewareProvider interface { Middleware(next http.Handler) http.Handler } +// Reporter defines error reporting service +type Reporter interface { + Report(w http.ResponseWriter, code int) +} + // Timeouts consolidate timeouts for both server and transport type Timeouts struct { // server timeouts @@ -180,19 +186,7 @@ func (h *Http) proxyHandler() http.HandlerFunc { }, ErrorLog: log.ToStdLogger(log.Default(), "WARN"), } - - // default assetsHandler disabled, returns error on missing matches - assetsHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Printf("[WARN] no match for %s %s", r.URL.Hostname(), r.URL.Path) - http.Error(w, "Server error", http.StatusBadGateway) - }) - - if h.AssetsLocation != "" && h.AssetsWebRoot != "" { - fs, err := R.FileServer(h.AssetsWebRoot, h.AssetsLocation) - if err == nil { - assetsHandler = h.CacheControl.Middleware(fs).ServeHTTP - } - } + assetsHandler := h.assetsHandler() return func(w http.ResponseWriter, r *http.Request) { @@ -201,8 +195,13 @@ func (h *Http) proxyHandler() http.HandlerFunc { server = strings.Split(r.Host, ":")[0] } u, mt, ok := h.Match(server, r.URL.Path) - if !ok { - assetsHandler.ServeHTTP(w, r) + if !ok { // no route match + if h.isAssetRequest(r) { + assetsHandler.ServeHTTP(w, r) + return + } + log.Printf("[WARN] no match for %s %s", r.URL.Hostname(), r.URL.Path) + h.Reporter.Report(w, http.StatusBadGateway) return } @@ -210,7 +209,7 @@ func (h *Http) proxyHandler() http.HandlerFunc { case discovery.MTProxy: uu, err := url.Parse(u) if err != nil { - http.Error(w, "Server error", http.StatusBadGateway) + h.Reporter.Report(w, http.StatusBadGateway) return } log.Printf("[DEBUG] proxy to %s", uu) @@ -220,12 +219,12 @@ func (h *Http) proxyHandler() http.HandlerFunc { // static match result has webroot:location, i.e. /www:/var/somedir/ ae := strings.Split(u, ":") if len(ae) != 2 { // shouldn't happen - http.Error(w, "Server error", http.StatusInternalServerError) + h.Reporter.Report(w, http.StatusInternalServerError) return } fs, err := R.FileServer(ae[0], ae[1]) if err != nil { - http.Error(w, "Server error", http.StatusInternalServerError) + h.Reporter.Report(w, http.StatusInternalServerError) return } h.CacheControl.Middleware(fs).ServeHTTP(w, r) @@ -233,6 +232,26 @@ func (h *Http) proxyHandler() http.HandlerFunc { } } +func (h *Http) assetsHandler() http.HandlerFunc { + if h.AssetsLocation == "" || h.AssetsWebRoot == "" { + return func(writer http.ResponseWriter, request *http.Request) {} + } + fs, err := R.FileServer(h.AssetsWebRoot, h.AssetsLocation) + if err != nil { + log.Printf("[WARN] can't initialize assets server, %v", err) + return func(writer http.ResponseWriter, request *http.Request) {} + } + return h.CacheControl.Middleware(fs).ServeHTTP +} + +func (h *Http) isAssetRequest(r *http.Request) bool { + if h.AssetsLocation == "" || h.AssetsWebRoot == "" { + return false + } + root := strings.TrimSuffix(h.AssetsWebRoot, "/") + return r.URL.Path == root || strings.HasPrefix(r.URL.Path, root+"/") +} + func (h *Http) toHTTP(address string, httpPort int) string { rx := regexp.MustCompile(`(.*):(\d*)`) return rx.ReplaceAllString(address, "$1:") + strconv.Itoa(httpPort) diff --git a/app/proxy/proxy_test.go b/app/proxy/proxy_test.go index bb590bd..3d79c11 100644 --- a/app/proxy/proxy_test.go +++ b/app/proxy/proxy_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "io/ioutil" "math/rand" "net/http" "net/http/httptest" @@ -22,7 +23,8 @@ import ( func TestHttp_Do(t *testing.T) { port := rand.Intn(10000) + 40000 h := Http{Timeouts: Timeouts{ResponseHeader: 200 * time.Millisecond}, Address: fmt.Sprintf("127.0.0.1:%d", port), - AccessLog: io.Discard, Signature: true, ProxyHeaders: []string{"hh1:vv1", "hh2:vv2"}, StdOutEnabled: true} + AccessLog: io.Discard, Signature: true, ProxyHeaders: []string{"hh1:vv1", "hh2:vv2"}, StdOutEnabled: true, + Reporter: &ErrorReporter{Nice: true}} ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() @@ -92,7 +94,10 @@ func TestHttp_Do(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusBadGateway, resp.StatusCode) - + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(b), "Sorry for the inconvenience") + assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) } } @@ -100,7 +105,7 @@ func TestHttp_DoWithAssets(t *testing.T) { port := rand.Intn(10000) + 40000 cc := NewCacheControl(time.Hour * 12) h := Http{Timeouts: Timeouts{ResponseHeader: 200 * time.Millisecond}, Address: fmt.Sprintf("127.0.0.1:%d", port), - AccessLog: io.Discard, AssetsWebRoot: "/static", AssetsLocation: "testdata", CacheControl: cc} + AccessLog: io.Discard, AssetsWebRoot: "/static", AssetsLocation: "testdata", CacheControl: cc, Reporter: &ErrorReporter{Nice: false}} ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() @@ -163,13 +168,24 @@ func TestHttp_DoWithAssets(t *testing.T) { assert.Equal(t, "public, max-age=43200", resp.Header.Get("Cache-Control")) } + { + resp, err := client.Get("http://localhost:" + strconv.Itoa(port) + "/svcbad") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "Server error") + assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) + } + } func TestHttp_DoWithAssetRules(t *testing.T) { port := rand.Intn(10000) + 40000 cc := NewCacheControl(time.Hour * 12) h := Http{Timeouts: Timeouts{ResponseHeader: 200 * time.Millisecond}, Address: fmt.Sprintf("127.0.0.1:%d", port), - AccessLog: io.Discard, CacheControl: cc} + AccessLog: io.Discard, CacheControl: cc, Reporter: &ErrorReporter{}} ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() @@ -256,3 +272,30 @@ func TestHttp_toHttp(t *testing.T) { } } + +func TestHttp_isAssetRequest(t *testing.T) { + tbl := []struct { + req string + assetsLocation string + assetsWebRoot string + res bool + }{ + {"/static/123.html", "/tmp", "/static", true}, + {"/static/123.html", "/tmp", "/static/", true}, + {"/static", "/tmp", "/static", true}, + {"/static/", "/tmp", "/static", true}, + {"/bad/", "/tmp", "/static", false}, + {"/static/", "", "/static", false}, + {"/static/", "/tmp", "", false}, + } + + for i, tt := range tbl { + t.Run(strconv.Itoa(i), func(t *testing.T) { + h := Http{AssetsLocation: tt.assetsLocation, AssetsWebRoot: tt.assetsWebRoot} + r, err := http.NewRequest("GET", tt.req, nil) + require.NoError(t, err) + assert.Equal(t, tt.res, h.isAssetRequest(r)) + }) + } + +} diff --git a/app/proxy/testdata/errtmpl.html b/app/proxy/testdata/errtmpl.html new file mode 100644 index 0000000..7293c19 --- /dev/null +++ b/app/proxy/testdata/errtmpl.html @@ -0,0 +1 @@ +oh my! {{.ErrCode}} - {{.ErrMessage}} \ No newline at end of file