diff --git a/conf/frps_full_example.toml b/conf/frps_full_example.toml index df69f25b38c..80535e6d650 100644 --- a/conf/frps_full_example.toml +++ b/conf/frps_full_example.toml @@ -154,6 +154,32 @@ subDomainHost = "frps.com" # custom 404 page for HTTP requests # custom404Page = "/path/to/404.html" +# custom error pages for HTTP status codes generated by frps itself, such as +# 404 (no route found), 504 (proxy timeout) and 401/407 (auth failed). The +# body is inline content, so no files need to be mounted into the frps +# container. For status code 404, custom404Page takes precedence over an +# entry with statusCode 404. +# [[customErrorPages]] +# statusCode = 404 +# contentType = "text/html" +# body = """ +# +# +#
Contact support: support@example.com
+# +# +# """ +# +# [[customErrorPages]] +# statusCode = 504 +# contentType = "text/html" +# body = "Service temporarily unavailable, please try again later." +# [customErrorPages.headers] +# Retry-After = "30" + # specify udp packet size, unit is byte. If not set, the default value is 1500. # This parameter should be same between client and server. # It affects the udp and sudp proxy. diff --git a/pkg/config/v1/server.go b/pkg/config/v1/server.go index a729fbda7b5..0dfb866956d 100644 --- a/pkg/config/v1/server.go +++ b/pkg/config/v1/server.go @@ -66,6 +66,12 @@ type ServerConfig struct { // Custom404Page specifies a path to a custom 404 page to display. If this // value is "", a default page will be displayed. Custom404Page string `json:"custom404Page,omitempty"` + // CustomErrorPages defines custom error pages for HTTP status codes + // generated by frps itself, such as 404 (no route found) and 504 (proxy + // timeout). The response body is inline content, so no files need to be + // mounted into the frps container. For status code 404, custom404Page + // takes precedence over an entry with statusCode 404. + CustomErrorPages []CustomErrorPage `json:"customErrorPages,omitempty"` SSHTunnelGateway SSHTunnelGateway `json:"sshTunnelGateway,omitempty"` @@ -205,6 +211,21 @@ type TLSServerConfig struct { TLSConfig } +// CustomErrorPage defines a custom error page for a specific HTTP status +// code. +type CustomErrorPage struct { + // StatusCode is the HTTP status code this page responds with. It must be + // in the range [400, 599]. + StatusCode int `json:"statusCode"` + // ContentType is the Content-Type of the response. If empty, "text/html" + // will be used. + ContentType string `json:"contentType,omitempty"` + // Body is the inline response body. + Body string `json:"body"` + // Headers contains additional response headers. + Headers map[string]string `json:"headers,omitempty"` +} + type SSHTunnelGateway struct { BindPort int `json:"bindPort,omitempty"` PrivateKeyFile string `json:"privateKeyFile,omitempty"` diff --git a/pkg/config/v1/validation/server.go b/pkg/config/v1/validation/server.go index d7cf242c47d..e07a15086f9 100644 --- a/pkg/config/v1/validation/server.go +++ b/pkg/config/v1/validation/server.go @@ -61,6 +61,17 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err errs = AppendError(errs, fmt.Errorf("invalid transport.maxPoolCount, must be non-negative")) } + seenStatusCodes := make(map[int]struct{}, len(c.CustomErrorPages)) + for _, p := range c.CustomErrorPages { + if p.StatusCode < 400 || p.StatusCode > 599 { + errs = AppendError(errs, fmt.Errorf("invalid customErrorPages statusCode %d, must be in the range [400, 599]", p.StatusCode)) + } + if _, ok := seenStatusCodes[p.StatusCode]; ok { + errs = AppendError(errs, fmt.Errorf("duplicate customErrorPages statusCode %d", p.StatusCode)) + } + seenStatusCodes[p.StatusCode] = struct{}{} + } + for _, p := range c.HTTPPlugins { if !lo.Every(SupportedHTTPPluginOps, p.Ops) { errs = AppendError(errs, fmt.Errorf("invalid http plugin ops, optional values are %v", SupportedHTTPPluginOps)) diff --git a/pkg/util/vhost/http.go b/pkg/util/vhost/http.go index 9394e9130cf..8f56103ceb2 100644 --- a/pkg/util/vhost/http.go +++ b/pkg/util/vhost/http.go @@ -127,14 +127,17 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) * ErrorLog: stdlog.New(log.NewWriteLogger(log.WarnLevel, 2), "", 0), ErrorHandler: func(rw http.ResponseWriter, req *http.Request, err error) { log.Logf(log.WarnLevel, 1, "do http proxy request [host: %s] error: %v", req.Host, err) - if err != nil { - if e, ok := err.(net.Error); ok && e.Timeout() { - rw.WriteHeader(http.StatusGatewayTimeout) - return - } + statusCode := http.StatusNotFound + if e, ok := err.(net.Error); ok && e.Timeout() { + statusCode = http.StatusGatewayTimeout + } + if statusCode == http.StatusNotFound { + writeErrorPage(rw, statusCode, resolveNotFoundPage()) + return + } + if !writeCustomErrorPage(rw, statusCode) { + rw.WriteHeader(statusCode) } - rw.WriteHeader(http.StatusNotFound) - _, _ = rw.Write(getNotFoundPageContent()) }, } rp.proxy = proxy @@ -291,10 +294,14 @@ func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) if !checkRouteAuthByRequest(req, rc) { if req.URL.Host != "" { rw.Header().Set("Proxy-Authenticate", `Basic realm="Restricted"`) - http.Error(rw, http.StatusText(http.StatusProxyAuthRequired), http.StatusProxyAuthRequired) + if !writeCustomErrorPage(rw, http.StatusProxyAuthRequired) { + http.Error(rw, http.StatusText(http.StatusProxyAuthRequired), http.StatusProxyAuthRequired) + } } else { rw.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) - http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + if !writeCustomErrorPage(rw, http.StatusUnauthorized) { + http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + } } return } diff --git a/pkg/util/vhost/resource.go b/pkg/util/vhost/resource.go index a65e29973ea..fe3e35c6cfa 100644 --- a/pkg/util/vhost/resource.go +++ b/pkg/util/vhost/resource.go @@ -26,6 +26,26 @@ import ( var NotFoundPagePath = "" +// CustomErrorPage defines a custom error page for a specific HTTP status code. +// It is used for errors generated by frps itself, such as 404 (no route found) +// and 504 (proxy timeout). +type CustomErrorPage struct { + // ContentType is the Content-Type of the response. If empty, "text/html" + // will be used. + ContentType string + // Body is the inline response body. + Body string + // Headers contains additional response headers. + Headers map[string]string +} + +var customErrorPages = make(map[int]CustomErrorPage) + +// SetCustomErrorPages registers custom error pages keyed by HTTP status code. +func SetCustomErrorPages(pages map[int]CustomErrorPage) { + customErrorPages = pages +} + const ( NotFound = ` @@ -50,29 +70,65 @@ Please try again later. ` ) -func getNotFoundPageContent() []byte { - var ( - buf []byte - err error - ) +func (p CustomErrorPage) contentType() string { + if p.ContentType != "" { + return p.ContentType + } + return "text/html" +} + +// resolveNotFoundPage returns the error page used for 404 responses. A custom +// page file (NotFoundPagePath) takes precedence over a customErrorPages entry +// with status code 404, which in turn takes precedence over the built-in +// default page. +func resolveNotFoundPage() CustomErrorPage { if NotFoundPagePath != "" { - buf, err = os.ReadFile(NotFoundPagePath) + buf, err := os.ReadFile(NotFoundPagePath) if err != nil { log.Warnf("read custom 404 page error: %v", err) - buf = []byte(NotFound) + } else { + return CustomErrorPage{Body: string(buf)} } - } else { - buf = []byte(NotFound) } - return buf + if p, ok := customErrorPages[http.StatusNotFound]; ok { + return p + } + return CustomErrorPage{Body: NotFound} +} + +// writeErrorPage writes statusCode and the given error page to rw. +func writeErrorPage(rw http.ResponseWriter, statusCode int, p CustomErrorPage) { + for k, v := range p.Headers { + rw.Header().Set(k, v) + } + rw.Header().Set("server", "frp/"+version.Full()) + rw.Header().Set("Content-Type", p.contentType()) + rw.WriteHeader(statusCode) + _, _ = rw.Write([]byte(p.Body)) +} + +// writeCustomErrorPage writes the custom error page registered for statusCode +// to rw. It reports whether a custom page was written. +func writeCustomErrorPage(rw http.ResponseWriter, statusCode int) bool { + p, ok := customErrorPages[statusCode] + if !ok { + return false + } + writeErrorPage(rw, statusCode, p) + return true } func NotFoundResponse() *http.Response { + p := resolveNotFoundPage() + header := make(http.Header) + for k, v := range p.Headers { + header.Set(k, v) + } header.Set("server", "frp/"+version.Full()) - header.Set("Content-Type", "text/html") + header.Set("Content-Type", p.contentType()) - content := getNotFoundPageContent() + content := []byte(p.Body) res := &http.Response{ Status: "Not Found", StatusCode: 404, diff --git a/pkg/util/vhost/resource_test.go b/pkg/util/vhost/resource_test.go new file mode 100644 index 00000000000..979bff9a5e8 --- /dev/null +++ b/pkg/util/vhost/resource_test.go @@ -0,0 +1,106 @@ +package vhost + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func resetErrorPageState(t *testing.T) { + t.Helper() + oldPath, oldPages := NotFoundPagePath, customErrorPages + NotFoundPagePath = "" + customErrorPages = make(map[int]CustomErrorPage) + t.Cleanup(func() { + NotFoundPagePath = oldPath + customErrorPages = oldPages + }) +} + +func TestResolveNotFoundPage(t *testing.T) { + t.Run("default page", func(t *testing.T) { + resetErrorPageState(t) + p := resolveNotFoundPage() + require.Equal(t, NotFound, p.Body) + require.Equal(t, "text/html", p.contentType()) + }) + + t.Run("custom error page for 404", func(t *testing.T) { + resetErrorPageState(t) + customErrorPages[http.StatusNotFound] = CustomErrorPage{ + ContentType: "text/plain", + Body: "custom not found", + Headers: map[string]string{"X-Custom": "yes"}, + } + p := resolveNotFoundPage() + require.Equal(t, "custom not found", p.Body) + require.Equal(t, "text/plain", p.contentType()) + require.Equal(t, "yes", p.Headers["X-Custom"]) + }) + + t.Run("custom404Page file takes precedence", func(t *testing.T) { + resetErrorPageState(t) + customErrorPages[http.StatusNotFound] = CustomErrorPage{Body: "inline"} + file := filepath.Join(t.TempDir(), "404.html") + require.NoError(t, os.WriteFile(file, []byte("from file"), 0o644)) + NotFoundPagePath = file + + p := resolveNotFoundPage() + require.Equal(t, "from file", p.Body) + }) + + t.Run("missing custom404Page file falls back to inline page", func(t *testing.T) { + resetErrorPageState(t) + customErrorPages[http.StatusNotFound] = CustomErrorPage{Body: "inline"} + NotFoundPagePath = filepath.Join(t.TempDir(), "missing.html") + + p := resolveNotFoundPage() + require.Equal(t, "inline", p.Body) + }) +} + +func TestWriteCustomErrorPage(t *testing.T) { + t.Run("no custom page", func(t *testing.T) { + resetErrorPageState(t) + rw := httptest.NewRecorder() + require.False(t, writeCustomErrorPage(rw, http.StatusGatewayTimeout)) + require.Equal(t, http.StatusOK, rw.Code) + }) + + t.Run("writes custom page", func(t *testing.T) { + resetErrorPageState(t) + customErrorPages[http.StatusGatewayTimeout] = CustomErrorPage{ + Body: "timeout page", + ContentType: "text/plain", + Headers: map[string]string{"Retry-After": "30"}, + } + rw := httptest.NewRecorder() + require.True(t, writeCustomErrorPage(rw, http.StatusGatewayTimeout)) + require.Equal(t, http.StatusGatewayTimeout, rw.Code) + require.Equal(t, "text/plain", rw.Header().Get("Content-Type")) + require.Equal(t, "30", rw.Header().Get("Retry-After")) + require.Equal(t, "timeout page", rw.Body.String()) + }) +} + +func TestNotFoundResponseCustomPage(t *testing.T) { + resetErrorPageState(t) + customErrorPages[http.StatusNotFound] = CustomErrorPage{ + Body: "inline not found", + ContentType: "application/json", + } + + res := NotFoundResponse() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + require.Equal(t, "application/json", res.Header.Get("Content-Type")) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, "inline not found", string(body)) + require.Equal(t, int64(len("inline not found")), res.ContentLength) +} diff --git a/server/service.go b/server/service.go index 203bef14251..c06815f262e 100644 --- a/server/service.go +++ b/server/service.go @@ -208,6 +208,16 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) { // Init 404 not found page vhost.NotFoundPagePath = cfg.Custom404Page + customErrorPages := make(map[int]vhost.CustomErrorPage, len(cfg.CustomErrorPages)) + for _, p := range cfg.CustomErrorPages { + customErrorPages[p.StatusCode] = vhost.CustomErrorPage{ + ContentType: p.ContentType, + Body: p.Body, + Headers: p.Headers, + } + } + vhost.SetCustomErrorPages(customErrorPages) + var ( httpMuxOn bool httpsMuxOn bool