mirror of
https://github.com/umputun/reproxy.git
synced 2025-09-16 08:46:17 +02:00
Nice error (#61)
* add support of html error reporting with custom templates * typo * formatting * better template load error msg
This commit is contained in:
@@ -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
|
||||
|
||||
|
27
app/main.go
27
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}
|
||||
|
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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"
|
||||
|
74
app/proxy/error_reporter.go
Normal file
74
app/proxy/error_reporter.go
Normal file
@@ -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 = `
|
||||
<!doctype html>
|
||||
<title>{{.ErrMessage}}</title>
|
||||
<style>
|
||||
body { text-align: center; padding: 150px; }
|
||||
h1 { font-size: 50px; }
|
||||
body { font: 20px Helvetica, sans-serif; color: #333; }
|
||||
article { display: block; text-align: left; width: 650px; margin: 0 auto; }
|
||||
a { color: #dc8100; text-decoration: none; }
|
||||
a:hover { color: #333; text-decoration: none; }
|
||||
</style>
|
||||
|
||||
<article>
|
||||
<h1>We’ll be back soon!</h1>
|
||||
<div>
|
||||
<p>Sorry for the inconvenience but we’re performing some maintenance at the moment. We’ll be back online shortly!</p>
|
||||
<p>— The Team</p>
|
||||
</div>
|
||||
</article>
|
||||
`
|
33
app/proxy/error_reporter_test.go
Normal file
33
app/proxy/error_reporter_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestErrorReporter_ReportShort(t *testing.T) {
|
||||
er := ErrorReporter{}
|
||||
wr := httptest.NewRecorder()
|
||||
er.Report(wr, 502)
|
||||
assert.Equal(t, 502, wr.Code)
|
||||
assert.Equal(t, "Server error\n", wr.Body.String())
|
||||
}
|
||||
|
||||
func TestErrorReporter_ReportNice(t *testing.T) {
|
||||
er := ErrorReporter{Nice: true}
|
||||
wr := httptest.NewRecorder()
|
||||
er.Report(wr, 502)
|
||||
assert.Equal(t, 502, wr.Code)
|
||||
assert.Contains(t, wr.Body.String(), "<title>Bad Gateway</title>")
|
||||
assert.Contains(t, wr.Body.String(), "<p>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())
|
||||
}
|
@@ -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)
|
||||
|
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
1
app/proxy/testdata/errtmpl.html
vendored
Normal file
1
app/proxy/testdata/errtmpl.html
vendored
Normal file
@@ -0,0 +1 @@
|
||||
oh my! {{.ErrCode}} - {{.ErrMessage}}
|
Reference in New Issue
Block a user