1
0
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:
Umputun
2021-04-30 04:03:36 -05:00
committed by GitHub
parent 06e78b81d4
commit 5743109210
9 changed files with 233 additions and 24 deletions

View File

@@ -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

View File

@@ -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}

View File

@@ -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))
}
}

View File

@@ -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"

View 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&rsquo;ll be back soon!</h1>
<div>
<p>Sorry for the inconvenience but we&rsquo;re performing some maintenance at the moment. We&rsquo;ll be back online shortly!</p>
<p>&mdash; The Team</p>
</div>
</article>
`

View 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())
}

View File

@@ -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)

View File

@@ -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
View File

@@ -0,0 +1 @@
oh my! {{.ErrCode}} - {{.ErrMessage}}